Fix SIE multi-year P&L accumulation, Fortnox supplier filter, processing_history (#267)

* fix: prevent P&L accumulation when importing multi-year SIE files

The opening-balance fallback summed all prior journal lines without
distinguishing balance sheet (class 1-2) from P&L (class 3-8). When
users imported one SIE file per year without running year-end closing
between them, resultatkonton accumulated across years instead of
resetting at each räkenskapsårsskifte. Reported by a customer.

Skip class 3-8 in the fallback path. P&L accounts must reset to zero
each fiscal year (årets resultat → 2099 → equity).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: only import unpaid supplier invoices from Fortnox

Fortnox's /supplierinvoices list endpoint doesn't reliably expose
FullyPaid, which caused historic paid invoices to be imported as
unpaid. Switch to the ?filter=unpaid query and surface that scope
in the migration options UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: add processing_history table for behandlingshistorik

Append-only event log per BFNAR 2013:2 kap 8. Includes:
- processing_history table with seq, correlation/causation chaining,
  aggregate (Document/BankTransaction/MatchProposal/Verifikation/etc.),
  open event_type validated against processing_event_types registry.
- Immutability via audit_log_immutable trigger (no UPDATE/DELETE).
- RLS scoped to user_company_ids; writes via service role only.
- appendProcessingHistory() helper with PII guard rejecting payloads
  containing personnummer/orgnr patterns.
- Shared TS types in types/index.ts.

No consumers wired yet — this is the persistence layer only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add swedish-project-accounting skill

Reference skill covering projektredovisning: dimensional tagging,
WIP accounting, K2/K3 revenue recognition (successiv vinstavräkning,
färdigställandemetoden), entreprenadavtal, BAS patterns (1470,
1620, 2420, 2450, 4970), and SIE4 #DIM 6 encoding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: rename processing_history migration to avoid timestamp collision

Main already has 20260418120000_allow_retroactive_first_fiscal_year.sql
from #265. Bumping this migration's timestamp to 20260418130000 to
keep schema_migrations.version unique.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Greptile review on processing_history

- Add BEFORE DELETE immutability trigger so the service role can't
  silently remove rows. Mirrors the pattern from migration 014
  (audit_log_no_update + audit_log_no_delete) and satisfies the
  immutability claim in BFNAR 2013:2 kap 8. Delivered as a follow-up
  migration since the original was already applied in some envs.

- Tighten PII patterns with \b word boundaries to avoid false
  positives on Bankgiro numbers (123456-7890) and invoice references
  like 202312-1234.

- Extend PII validation to actor.label, which previously bypassed
  the payload guard despite the docblock explicitly forbidding
  names/emails/personnummer there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-18 13:26:41 +02:00
committed by GitHub
parent 8ed943198f
commit 44082ff845
14 changed files with 2021 additions and 4 deletions
@@ -0,0 +1,123 @@
---
name: swedish-project-accounting
description: Swedish project accounting (projektredovisning) covering dimensional tagging of bokföringsposter with project codes, WIP accounting (pågående arbeten), revenue recognition under K2 and K3 (successiv vinstavräkning, färdigställandemetoden), construction contracts (entreprenadavtal), BAS account patterns for project tracking (1470, 1620, 2420, 2450, 4970), SIE4 dimension encoding (#DIM 6, #OBJEKT, #TRANS object lists), project profitability reporting, overhead allocation (fördelningsnycklar), and the tax-accounting divergence for löpande räkning contracts. Trigger on ANY Swedish project accounting question including "projektredovisning", "pågående arbeten", "successiv vinstavräkning", "färdigställandegrad", "upparbetad ej fakturerad intäkt", "fakturerad ej upparbetad intäkt", "konto 1620", "konto 1470", "konto 2450", "entreprenaduppdrag", "projekt dimension SIE", "kostnadsställe vs projekt", "projektlönsamhet", "WIP accounting Sweden", "K3 kapitel 23", "befarad förlust projekt", "fördelningsnyckel", questions about how Fortnox/Visma/Bokio handle project dimensions, or any question about tracking intäkter/kostnader per project in Swedish bookkeeping. Also trigger when building software features for project accounting, designing data models for project dimensions, implementing revenue recognition logic, or handling SIE4 import/export of project-tagged transactions. Always use this skill over training data for project accounting topics.
---
# Swedish Project Accounting (Projektredovisning)
This skill covers the full technical, regulatory, and implementation landscape of project accounting in Swedish bookkeeping. It is designed for both answering compliance questions and building software that handles project dimensions.
## How to use this skill
1. Identify the user's context: are they asking a compliance question, building software, or troubleshooting bookkeeping entries?
2. Determine the applicable regulatory framework: K2 (BFNAR 2016:10) or K3 (BFNAR 2012:1). This choice fundamentally shapes available revenue recognition methods.
3. For detailed BAS account patterns, SIE4 encoding, or implementation data models, read the appropriate reference file before answering.
## Reference files
Read these before answering questions in the corresponding area:
| File | When to read |
|------|-------------|
| `references/accounts-and-entries.md` | Questions about specific BAS accounts (1470, 1620, 2450, 4970, etc.), booking patterns, journal entries for WIP adjustments, or pågående arbeten entries |
| `references/k2-k3-revenue-recognition.md` | Questions about successiv vinstavräkning, färdigställandemetoden, K3 Chapter 23, K2 Chapter 6, construction contracts, befarade förluster, or choosing between K2/K3 for project-intensive companies |
| `references/sie4-project-dimensions.md` | Questions about SIE4 encoding of project dimensions, #DIM/#OBJEKT/#TRANS records, import/export of project-tagged data, or how dimensions map to Fortnox/Visma/Bokio |
| `references/tax-and-grants.md` | Questions about tax treatment of pågående arbeten, materiellt samband, löpande räkning tax divergence, forskningsavdrag, aktivering av utvecklingsutgifter, or EU grant accounting |
| `references/implementation-patterns.md` | Questions about data models, project lifecycle, WIP calculation logic, overhead allocation, profitability reporting, time tracking integration, or software architecture for project accounting |
## Core concepts (always available in context)
### What is projektredovisning?
Projektredovisning tags individual transaction lines (bokföringsposter) with project codes alongside BAS account numbers. Same ledger, extra dimension. It enables tracking intäkter och kostnader per project rather than only per account.
Projects are never encoded in the account number itself. They exist as a separate dimensional layer called objektredovisning. The 4-digit BAS account captures the *what* (cost/revenue type); the project dimension captures the *where/for whom*.
### Projekt vs kostnadsställe
These are distinct concepts that complement each other:
- **Kostnadsställe** (cost center): permanent organizational unit (department, branch). No end date. SIE dimension 1.
- **Projekt**: time-limited initiative with start/end dates and accumulated multi-year balances. SIE dimension 6.
A project typically belongs to one kostnadsställe. Reports can cross-reference both dimensions.
### When is project accounting required?
BFL does not mandate project accounting. However:
- **K3 Chapter 23** requires successiv vinstavräkning for fixed-price contracts in koncernredovisning. Calculating färdigställandegrad is impossible without project-level cost tracking, making it effectively mandatory.
- **K2 Chapter 6** offers a choice between huvudregeln and alternativregeln for fixed-price contracts. Both require per-project cost accumulation.
- **Any company doing consulting, construction, R&D, or grant-funded work** needs project accounting for management purposes even without a regulatory mandate.
### Key BAS accounts (summary)
| Account | Name | Purpose |
|---------|------|---------|
| 1470 | Pågående arbeten | WIP asset under alternativregeln (completed contract) |
| 1620 | Upparbetad men ej fakturerad intäkt | WIP receivable under successiv vinstavräkning |
| 2420 | Förskott från kunder | Customer advance payments |
| 2450 | Fakturerad men ej upparbetad intäkt | Deferred revenue (invoiced > earned) |
| 4970 | Förändring pågående arbeten | P&L counterpart for 1470 adjustments |
| 3041 | Försäljning tjänster 25% | Typical project revenue account |
| 3980 | Erhållna offentliga bidrag | Grant revenue |
### Revenue recognition decision tree
```
Is the contract fixed-price or time-and-materials?
├─ Time-and-materials (löpande räkning)
│ └─ Both K2 and K3: recognize revenue as work is performed
│ Tax: may diverge from accounting (HFD 2011 ref. 20)
└─ Fixed-price (fast pris)
├─ K3 (koncernredovisning): successiv vinstavräkning MANDATORY
│ └─ Recognize revenue × färdigställandegrad at each balance date
│ Can outcome be reliably estimated? All four conditions met?
│ ├─ Yes: revenue = total contract × completion %
│ └─ No: revenue = costs incurred (zero profit recognized)
├─ K3 (juridisk person): successiv vinstavräkning OR
│ alternativregeln (per punkt 23.31, requires 17 kap. 23 § IL)
└─ K2: huvudregeln (completion %) OR alternativregeln
└─ Alternativregeln: recognize when "väsentligen fullgjort"
(Srf U 15: assessed from customer acceptance perspective)
```
### Befarade förluster
K3 punkt 23.32: if total estimated costs exceed total contract revenue, the expected loss must be recognized as a cost IMMEDIATELY, regardless of completion percentage. This is mandatory and overrides normal recognition logic. The engine must flag projects where cumulative actual + estimated remaining costs exceed contract revenue.
### Moms timing mismatch
This is the highest-error-rate area in project accounting:
- **Upparbetad ej fakturerad intäkt (1620)**: pure periodisering, NO moms impact. VAT is only triggered when an actual invoice is issued.
- **Fakturerad ej upparbetad intäkt (2450)**: moms on those invoices must be reported AT TIME OF INVOICING even though accounting books revenue as liability.
- **Omvänd skattskyldighet** in construction: seller invoices without VAT when buyer "more than temporarily" sells byggtjänster. Per-project tracking of reverse charge status required.
The engine must track moms reporting independently from revenue recognition on every project.
### Gross reporting requirement
Per Srf U 14, pågående arbeten must be reported GROSS per project in the balance sheet. Netting across projects is prohibited (ÅRL kvittningsförbud). A project with 1620 balance and another with 2450 balance must show both, not net them.
### Software vendor comparison
| System | Dimension model | SIE compatibility |
|--------|----------------|-------------------|
| Fortnox | Project + CostCenter (flat, independent) | Direct mapping to dim 1+6 |
| Visma | Objekt 1 + 2 (renamable), Flerårigt flag for multi-year | Flerårigt maps to projekt, non-flerårigt to KS |
| Bokio | Unlimited Tag Groups | Tags don't export as SIE dimensions |
### Common error patterns
1. **Incorrect färdigställandegrad**: over/under-recognition of revenue. Flag projects where completion % diverges >20% from time-elapsed or budget-consumed ratios.
2. **Missing project tags**: orphaned costs. Enforce MANDATORY project code on accounts flagged in dimension settings.
3. **Mixing recognition methods**: without disclosure violates consistency. Lock method per project type at company config level.
4. **Unrecognized befarade förluster**: automatic detection required per K3 23.32.
5. **Incomplete project closings**: residual balances on 1620/2450/1470. Enforce zero-balance check before CLOSED status.
6. **Missing garantiavsättningar**: common audit finding for construction. Prompt at project close.
7. **VAT-revenue timing mismatch**: booking 1620 entries with moms, or failing to report moms on advance invoices.
@@ -0,0 +1,247 @@
# BAS Accounts and Journal Entries for Project Accounting
## Table of contents
1. Which account classes get project-tagged
2. Core WIP accounts in detail
3. Journal entry patterns by scenario
4. Gemensamma kostnader and fördelningsnycklar
5. Project closing entries
---
## 1. Which account classes get project-tagged
Project dimensions apply primarily to P&L accounts but extend to specific balance sheet accounts that are inherently project-related.
**Class 3xxx (intäkter)** -- ALWAYS project-tagged. Core for tracking project revenue.
- 3010 Försäljning varor
- 3040/3041 Försäljning av tjänster (6%/25%)
- 3310 Upparbetad intäkt adjustments (used by some systems)
- 3980/3981 Erhållna offentliga bidrag
**Class 4xxx (varor/material)** -- ALWAYS project-tagged as direct project costs.
- 4010 Varuinköp
- 4970 Förändring av pågående arbeten (P&L counterpart for 1470)
**Class 5xxx-6xxx (övriga kostnader)** -- COMMONLY tagged when directly attributable.
- 5010 Lokalhyra (if project-specific premises)
- 6110 Kontorsmaterial
- 6210 Telekommunikation
- 6530 Redovisningstjänster (if project-specific)
- Shared costs allocated via fördelningsnycklar (see section 4)
**Class 7xxx (personalkostnader)** -- COMMONLY tagged via employee time reports.
- 7010/7210 Löner till tjänstemän/kollektivanställda
- 7510 Arbetsgivaravgifter
- 7090/7290 Förändring semesterlöneskuld (should follow wage distribution)
**Class 1xxx/2xxx (balance sheet)** -- SELECTIVELY tagged. Only inherently project-related accounts:
- 1470 Pågående arbeten för annans räkning
- 1620 Upparbetad men ej fakturerad intäkt
- 2420 Förskott från kunder
- 2450 Fakturerad men ej upparbetad intäkt
- 1510 Kundfordringar (sometimes, for project-level receivables tracking)
**Class 8xxx (finansiella poster)** -- RARELY project-tagged.
**Class 9xxx (internredovisning)** -- Used for internal allocation between projects/cost centers. Debit and credit must balance within internal transfers. Never mixed with external accounts in the same report.
---
## 2. Core WIP accounts in detail
### 1470 Pågående arbeten för annans räkning
- Balance sheet: Omsättningstillgångar > Varulager
- Used under: alternativregeln (completed contract method)
- Purpose: capitalize project costs as inventory until assignment is substantially complete
- Normal balance: Debet
- Booking: Debet 1470 / Kredit 4970
Sub-accounts in practice:
- 1471 Pågående arbeten, nedlagda kostnader
- 1478 Pågående arbeten, fakturerat (contra, kredit)
Under K2 (alternativregeln), 1470 is valued at anskaffningsvärde (direct costs only). Under K3, the alternativregeln in juridisk person also uses 1470 but with a broader cost base.
### 1620 Upparbetad men ej fakturerad intäkt
- Balance sheet: Omsättningstillgångar > Kortfristiga fordringar
- Used under: successiv vinstavräkning (percentage of completion)
- Purpose: when recognized revenue exceeds invoiced amounts, carry difference as receivable
- Normal balance: Debet
- Booking: Debet 1620 / Kredit 3xxx (revenue account)
- CRITICAL: this entry carries NO MOMS. It is a pure periodisering.
Sub-accounts:
- 1620F Upparbetad ej fakturerad intäkt, fastpris
- 1620L Upparbetad ej fakturerad intäkt, löpande räkning
### 2450 Fakturerad men ej upparbetad intäkt
- Balance sheet: Kortfristiga skulder
- Used under: successiv vinstavräkning
- Purpose: when invoiced amounts exceed recognized revenue, carry difference as liability
- Normal balance: Kredit
- Booking: Debet 3xxx / Kredit 2450
- CRITICAL: moms on the underlying invoices must be reported at time of invoicing even though revenue is deferred
### 2420 Förskott från kunder
- Records customer advance payments before work begins
- Similar to 2450 but used for payments received before any invoicing/work
### 4970 Förändring av pågående arbeten
- P&L counterpart for 1470 adjustments
- Purpose: neutralize cost impact during project execution under alternativregeln
- Normal balance: Kredit (increases 1470 asset) or Debet (decreases 1470 at project completion)
### 38xx Aktiverat arbete för egen räkning
- 3800/3840 Aktiverat arbete för egen räkning
- Used when a company capitalizes internally generated assets (software, R&D)
- Project collects costs; at period end, capitalization entry: Debet 10xx (tillgång) / Kredit 38xx
- Only permitted under K3 aktiveringsmodellen. Prohibited under K2 (punkt 10.4).
---
## 3. Journal entry patterns by scenario
### Scenario A: Successiv vinstavräkning, upparbetad > fakturerad
Project X: total contract 1,000,000 SEK. Estimated total cost 800,000. At balance date: 400,000 costs incurred, 300,000 invoiced.
Färdigställandegrad = 400,000 / 800,000 = 50%
Upparbetad intäkt = 1,000,000 × 50% = 500,000
Difference = 500,000 - 300,000 = 200,000 (upparbetad > fakturerad)
```
Debet 1620 {6 "X"} 200,000
Kredit 3041 {6 "X"} 200,000
```
No moms on this entry. Moms was reported on the 300,000 invoice when issued.
### Scenario B: Successiv vinstavräkning, fakturerad > upparbetad
Project Y: total contract 500,000 SEK. Estimated total cost 350,000. At balance date: 70,000 costs incurred, 200,000 invoiced (advance billing).
Färdigställandegrad = 70,000 / 350,000 = 20%
Upparbetad intäkt = 500,000 × 20% = 100,000
Difference = 200,000 - 100,000 = 100,000 (fakturerad > upparbetad)
```
Debet 3041 {6 "Y"} 100,000
Kredit 2450 {6 "Y"} 100,000
```
The moms on the 200,000 invoice was already reported. This entry is a pure revenue deferral.
### Scenario C: Alternativregeln (completed contract), costs during project
Project Z ongoing. This month: 50,000 in direct costs (materials + labor).
Record costs normally:
```
Debet 4010 {6 "Z"} 30,000 -- materials
Debet 7010 {6 "Z"} 20,000 -- labor
Kredit 2440 30,000 -- supplier invoice
Kredit 1930 20,000 -- bank (salary)
```
At period end, capitalize to WIP:
```
Debet 1470 {6 "Z"} 50,000
Kredit 4970 {6 "Z"} 50,000
```
This neutralizes the P&L impact until the project is substantially complete.
### Scenario D: Project completion under alternativregeln
Project Z is väsentligen fullgjort. Total accumulated 1470 balance: 300,000. Final invoice: 450,000.
Reverse the WIP capitalization:
```
Debet 4970 {6 "Z"} 300,000
Kredit 1470 {6 "Z"} 300,000
```
Revenue is now recognized and costs hit the P&L. The invoice creates the normal revenue/receivable/moms entries.
### Scenario E: Befarad förlust
Project W: total contract 200,000. Estimated total cost now revised to 250,000. Incurred so far: 120,000.
Expected loss = 250,000 - 200,000 = 50,000. Must be recognized IMMEDIATELY.
Under successiv vinstavräkning:
```
Debet 4970 {6 "W"} 50,000
Kredit 2290 {6 "W"} 50,000 -- avsättning befarad förlust
```
The loss provision is booked even though only 120,000/250,000 of costs have been incurred.
### Scenario F: Overhead allocation
Shared office rent 100,000/month. Three active projects consuming 40%, 35%, 25% based on headcount allocation key.
```
Kredit 5010 {1 "ADMIN"} 100,000 -- reverse from admin cost center
Debet 5010 {6 "P1"} 40,000
Debet 5010 {6 "P2"} 35,000
Debet 5010 {6 "P3"} 25,000
```
Or using internal accounts (kontoklass 9):
```
Kredit 9510 {1 "ADMIN"} 100,000
Debet 9510 {6 "P1"} 40,000
Debet 9510 {6 "P2"} 35,000
Debet 9510 {6 "P3"} 25,000
```
---
## 4. Gemensamma kostnader and fördelningsnycklar
Shared costs that cannot be directly attributed to a single project must be allocated using fördelningsnycklar (allocation keys).
### Common allocation bases
| Allocation key | Best for | Calculation |
|---------------|----------|-------------|
| Direkta lönekostnader | Service/consulting firms | Project salary / Total salary |
| Antal timmar | Time-based work | Project hours / Total hours |
| Omsättning | Revenue-proportional costs | Project revenue / Total revenue |
| Kvadratmeter | Facility costs | Project space / Total space |
| Antal anställda/FTE | HR overheads | Project FTE / Total FTE |
### The SUHF model
Widely adopted beyond universities. Applies a full markup (OH-påslag) on the salary base covering both overhead and premises. The markup is calculated annually as: (total indirect costs) / (total direct salary costs) × 100%.
### Rules for internal allocation
- External cost accounts (class 5-8) must not be mixed with internal allocation accounts (class 9) in the same report
- Debit and credit must balance within internal transfers
- Allocation frequency: monthly for management reporting, at minimum at year-end for statutory
- Document the allocation key and its basis in the accounting policy
---
## 5. Project closing entries
When a project transitions to CLOSED status, these entries must be verified:
1. **All costs booked**: no outstanding supplier invoices, accruals complete
2. **Final WIP adjustment**:
- Successiv vinstavräkning: clear 1620/2450 to zero for this project
- Alternativregeln: reverse 1470 via 4970
3. **Garantiavsättning** (construction projects): Debet 6320 / Kredit 2290, typically 2-5% of contract value
4. **Final invoice issued**: all revenue recognized, moms reported
5. **Interimsposter cleared**: no residual periodiseringar on 17xx/29xx for this project
6. **Balance verification**: run project trial balance, confirm all project-tagged accounts net to zero (P&L is allowed to have residual; balance sheet accounts must be zero)
@@ -0,0 +1,520 @@
# Implementation Patterns for Project Accounting
## Table of contents
1. Data model
2. Project lifecycle state machine
3. WIP calculation logic
4. Overhead allocation engine
5. Profitability reporting
6. Time tracking integration
7. Payroll distribution
8. Asset register integration
9. Period-end procedures
10. Validation and error detection
---
## 1. Data model
### Core tables
```sql
-- Project master
CREATE TABLE projects (
id UUID PRIMARY KEY,
project_number VARCHAR(20) UNIQUE NOT NULL, -- matches SIE object_no
name VARCHAR(200) NOT NULL,
description TEXT,
status TEXT CHECK (status IN ('SETUP','ACTIVE','CLOSED','ARCHIVED')) DEFAULT 'SETUP',
project_type TEXT CHECK (project_type IN (
'FIXED_PRICE','TIME_MATERIALS','TIME_LIMITED','ONGOING'
)) NOT NULL,
revenue_method TEXT CHECK (revenue_method IN (
'SUCCESSIV_VINSTAVRAKNING','FARDIGSTALLANDEMETODEN','LOPANDE_RAKNING'
)),
customer_id UUID REFERENCES customers(id),
cost_center_id UUID REFERENCES cost_centers(id),
start_date DATE,
end_date DATE,
contract_revenue DECIMAL(15,2), -- total contract amount (fixed-price)
budgeted_total_cost DECIMAL(15,2), -- estimated total cost
budgeted_hours DECIMAL(10,2),
is_rd_project BOOLEAN DEFAULT FALSE, -- for forskningsavdrag tracking
grant_funded BOOLEAN DEFAULT FALSE,
reverse_charge_default BOOLEAN DEFAULT FALSE, -- omvänd skattskyldighet
created_at TIMESTAMPTZ DEFAULT NOW(),
closed_at TIMESTAMPTZ,
sie_dimension INTEGER DEFAULT 6 -- SIE dimension number
);
-- Cost centers
CREATE TABLE cost_centers (
id UUID PRIMARY KEY,
code VARCHAR(20) UNIQUE NOT NULL,
name VARCHAR(200) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
sie_dimension INTEGER DEFAULT 1
);
-- Per-account dimension enforcement
CREATE TABLE account_dimension_settings (
account_number INTEGER NOT NULL,
dimension_type TEXT CHECK (dimension_type IN ('PROJECT','COST_CENTER')),
setting TEXT CHECK (setting IN ('ALLOWED','MANDATORY','NOT_ALLOWED')) DEFAULT 'ALLOWED',
PRIMARY KEY (account_number, dimension_type)
);
-- Journal entry lines with project/cost center
CREATE TABLE journal_entry_lines (
id UUID PRIMARY KEY,
journal_entry_id UUID NOT NULL REFERENCES journal_entries(id),
account_number INTEGER NOT NULL,
debit DECIMAL(15,2) DEFAULT 0,
credit DECIMAL(15,2) DEFAULT 0,
description TEXT,
project_id UUID REFERENCES projects(id), -- nullable
cost_center_id UUID REFERENCES cost_centers(id), -- nullable
quantity DECIMAL(10,2), -- for SIE quantity field
CONSTRAINT valid_amount CHECK (
(debit > 0 AND credit = 0) OR (credit > 0 AND debit = 0) OR (debit = 0 AND credit = 0)
)
);
-- Project budget lines (per account, per period)
CREATE TABLE project_budgets (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES projects(id),
account_number INTEGER NOT NULL,
period VARCHAR(6) NOT NULL, -- YYYYMM
amount DECIMAL(15,2) NOT NULL
);
-- WIP snapshots (calculated at each period close)
CREATE TABLE project_wip_snapshots (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES projects(id),
snapshot_date DATE NOT NULL,
actual_costs_to_date DECIMAL(15,2),
estimated_remaining_costs DECIMAL(15,2),
estimated_total_cost DECIMAL(15,2),
fardigstallandegrad DECIMAL(5,4), -- 0.0000 to 1.0000
contract_revenue DECIMAL(15,2),
upparbetad_intakt DECIMAL(15,2),
fakturerat_belopp DECIMAL(15,2),
wip_balance DECIMAL(15,2), -- 1620 or 2450 amount
wip_account INTEGER, -- 1620 or 2450
befarad_forlust DECIMAL(15,2),
tax_basis_revenue DECIMAL(15,2), -- for löpande räkning divergence
UNIQUE (project_id, snapshot_date)
);
-- Time entries (for integration with time tracking)
CREATE TABLE project_time_entries (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES projects(id),
employee_id UUID NOT NULL,
entry_date DATE NOT NULL,
hours DECIMAL(6,2) NOT NULL,
activity_type VARCHAR(50),
internal_rate DECIMAL(10,2), -- cost rate
external_rate DECIMAL(10,2), -- billing rate
is_billable BOOLEAN DEFAULT TRUE,
is_rd_activity BOOLEAN DEFAULT FALSE, -- for forskningsavdrag
invoiced BOOLEAN DEFAULT FALSE
);
```
### Indexes for project reporting
```sql
CREATE INDEX idx_jel_project ON journal_entry_lines(project_id) WHERE project_id IS NOT NULL;
CREATE INDEX idx_jel_cost_center ON journal_entry_lines(cost_center_id) WHERE cost_center_id IS NOT NULL;
CREATE INDEX idx_jel_account_project ON journal_entry_lines(account_number, project_id);
CREATE INDEX idx_time_project_date ON project_time_entries(project_id, entry_date);
CREATE INDEX idx_wip_project_date ON project_wip_snapshots(project_id, snapshot_date);
```
---
## 2. Project lifecycle state machine
```
SETUP ──────► ACTIVE ──────► CLOSED ──────► ARCHIVED
│ │
│ └──► ACTIVE (reopen with elevated permissions)
└──► CLOSED (skip if zero transactions)
```
### State transitions and validations
**SETUP → ACTIVE**
- Requires: project_number, name, project_type, revenue_method set
- For fixed-price: contract_revenue and budgeted_total_cost must be set
- Enables: transaction booking, time entry, invoicing
**ACTIVE → CLOSED**
- Pre-close checklist (all must pass):
1. No pending supplier invoices (kontrollera leverantörsskuld per project)
2. All time entries invoiced or written off
3. Final WIP adjustment posted (1620/2450/1470 cleared to zero)
4. Final invoice issued
5. Moms reported for all invoices
6. Garantiavsättning booked (if construction project)
7. Project trial balance: all balance sheet accounts net to zero
8. Residual periodiseringar cleared (17xx/29xx)
- On transition: record closed_at timestamp, freeze project from new transactions
- Resultatföring: project's accumulated P&L becomes part of company P&L
**CLOSED → ACTIVE (reopen)**
- Requires elevated permissions (admin/revisor role)
- Audit trail entry documenting reason for reopening
- Common reasons: late supplier invoice, warranty claim, grant audit adjustment
**CLOSED → ARCHIVED**
- Read-only, no modifications permitted
- Can be triggered after retention period (typically 7 years per BFL)
---
## 3. WIP calculation logic
### Successiv vinstavräkning (percentage of completion)
```python
def calculate_wip_successiv(project, period_date):
actual_costs = sum_project_costs(project.id, up_to=period_date)
estimated_total = project.budgeted_total_cost # or latest revised estimate
if estimated_total <= 0:
return None # cannot calculate
fardigstallandegrad = actual_costs / estimated_total
fardigstallandegrad = min(fardigstallandegrad, 1.0) # cap at 100%
upparbetad_intakt = project.contract_revenue * fardigstallandegrad
fakturerat = sum_project_invoiced(project.id, up_to=period_date)
wip_balance = upparbetad_intakt - fakturerat
# Check for befarad förlust
befarad_forlust = 0
if estimated_total > project.contract_revenue:
befarad_forlust = estimated_total - project.contract_revenue
if wip_balance > 0:
# Upparbetad > fakturerad: asset on 1620
return WIPResult(
account=1620, amount=wip_balance,
revenue_account=3041,
fardigstallandegrad=fardigstallandegrad,
befarad_forlust=befarad_forlust
)
else:
# Fakturerad > upparbetad: liability on 2450
return WIPResult(
account=2450, amount=abs(wip_balance),
revenue_account=3041,
fardigstallandegrad=fardigstallandegrad,
befarad_forlust=befarad_forlust
)
```
### Alternativregeln (completed contract)
```python
def calculate_wip_alternativ(project, period_date):
actual_costs = sum_project_costs(project.id, up_to=period_date)
fakturerat = sum_project_invoiced(project.id, up_to=period_date)
# Capitalize costs as inventory
# Net position determines balance sheet presentation
if actual_costs >= fakturerat:
# Asset: more costs than invoiced
return WIPResult(
account=1470, amount=actual_costs,
contra_account=4970,
fakturerat_offset=fakturerat # netted in disclosure
)
else:
# Liability: more invoiced than costs
net_liability = fakturerat - actual_costs
return WIPResult(
account=2430, amount=net_liability,
asset_account=1470, asset_amount=actual_costs,
contra_account=4970
)
```
### Löpande räkning
```python
def calculate_wip_lopande(project, period_date):
# Revenue = work performed at agreed rates
time_entries = get_unbilled_time(project.id, up_to=period_date)
upparbetad = sum(e.hours * e.external_rate for e in time_entries)
fakturerat = sum_project_invoiced(project.id, up_to=period_date)
wip_balance = upparbetad - fakturerat
# Tax basis: only invoiced amounts (HFD 2011 ref. 20)
tax_revenue = fakturerat
accounting_revenue = upparbetad
return WIPResult(
account=1620 if wip_balance > 0 else 2450,
amount=abs(wip_balance),
tax_basis_revenue=tax_revenue,
accounting_revenue=accounting_revenue,
tax_difference=accounting_revenue - tax_revenue
)
```
---
## 4. Overhead allocation engine
```python
def allocate_overhead(period, allocation_key_type, source_cost_center, target_projects):
"""
Allocate shared costs from a cost center to projects.
allocation_key_type: 'SALARY', 'HOURS', 'REVENUE', 'HEADCOUNT', 'SQMETERS'
"""
total_overhead = sum_costs(source_cost_center, period, account_range=(5000, 6999))
if allocation_key_type == 'SALARY':
denominators = {p: sum_salary_costs(p, period) for p in target_projects}
elif allocation_key_type == 'HOURS':
denominators = {p: sum_hours(p, period) for p in target_projects}
elif allocation_key_type == 'REVENUE':
denominators = {p: sum_revenue(p, period) for p in target_projects}
total_denominator = sum(denominators.values())
if total_denominator == 0:
return [] # no basis for allocation
allocations = []
for project, value in denominators.items():
share = value / total_denominator
amount = round(total_overhead * share, 2)
allocations.append(AllocationEntry(
source=source_cost_center,
target_project=project,
amount=amount,
key_type=allocation_key_type,
key_value=value,
share_pct=share
))
# Rounding adjustment: assign remainder to largest project
allocated_total = sum(a.amount for a in allocations)
if allocated_total != total_overhead:
diff = total_overhead - allocated_total
largest = max(allocations, key=lambda a: a.amount)
largest.amount += diff
return allocations
```
---
## 5. Profitability reporting
### Projektresultaträkning structure
```
Intäkter
Fakturerad intäkt (3xxx)
+ Upparbetad ej fakturerad (1620 change)
- Fakturerad ej upparbetad (2450 change)
= Nettointäkt
Direkta kostnader
Personalkostnader (7xxx tagged to project)
Materialkostnader (4xxx tagged to project)
Underleverantörer (tagged subcontractor costs)
Övriga direkta kostnader (5xxx/6xxx tagged to project)
= Summa direkta kostnader
Täckningsbidrag 1 = Nettointäkt - Direkta kostnader
Indirekta kostnader (allocated overhead)
OH-påslag personal
OH-påslag lokaler
OH-påslag administration
= Summa indirekta kostnader
Täckningsbidrag 2 = TB1 - Indirekta kostnader
Projektresultat = TB2
```
### Key KPIs
| KPI | Formula | Target |
|-----|---------|--------|
| Debiteringsgrad | Billable hours / Total hours | 65-85% (consulting) |
| Faktisk timpris | Project revenue / Billable hours | > Internal rate × 2.5 |
| Täckningsgrad | TB1 / Nettointäkt | > 50% (consulting), > 15% (construction) |
| Budget adherence | Actual cost / Budgeted cost | < 1.0 |
| Färdigställandegrad | Actual cost / Est. total cost | Should track time-elapsed |
| WIP days | WIP balance / (Revenue / 365) | < 30 days |
### Multi-project dashboard
The engine should support a project overview showing:
- All active projects with status indicators (on track, at risk, over budget)
- Aggregate WIP position (total 1620, total 2450, total 1470)
- Revenue pipeline (contracted but not yet recognized)
- Resource utilization across projects
- Cash flow impact (invoiced vs collected per project)
---
## 6. Time tracking integration
### Rate hierarchy
When calculating project costs from time entries, rates are resolved in priority order:
1. **Customer-specific rate** (override per customer contract)
2. **Project-specific rate** (override per project agreement)
3. **Activity-type rate** (e.g., senior consulting vs. junior)
4. **User role rate** (based on employee level/title)
5. **Default company rate** (fallback)
### Internal cost rate calculation
```
Internal rate = (Årslön + Arbetsgivaravgifter 31.42% + OH-påslag) / Produktiva timmar
Produktiva timmar = Total hours - Semester - Sjukdom - Utbildning - Intern tid
Typically 1,600-1,760 hours/year for full-time employee.
```
### Time entry → cost → WIP → invoice pipeline
```
Time Entry (hours × activity)
Cost Calculation (hours × internal rate → project cost)
WIP Calculation (upparbetad intäkt = hours × external rate)
Invoice Generation (group unbilled entries, create faktura)
Revenue Recognition (clear WIP, book revenue + moms)
```
---
## 7. Payroll distribution
### Salary booking with project tags
When employees work on multiple projects, their salary costs must be distributed based on time reports.
Monthly flow:
1. Calculate total bruttolön per employee
2. Get time distribution: percentage per project from time entries
3. Book salary proportionally:
```
Employee A: 50,000 SEK bruttolön, 60% Project X, 40% Project Y
Debet 7010 {6 "X"} 30,000
Debet 7010 {6 "Y"} 20,000
Kredit 2710 50,000
```
4. Arbetsgivaravgifter follow same distribution:
```
Avgifter = 50,000 × 31.42% = 15,710
Debet 7510 {6 "X"} 9,426
Debet 7510 {6 "Y"} 6,284
Kredit 2730 15,710
```
5. Semesterlöneskuld changes (7090/7290) should follow the same proportional distribution. Unallocated vacation liability distorts project profitability.
---
## 8. Asset register integration
### Project as cost collector for assets
Projects serve as cost collectors during development/construction phases. Upon completion:
1. Accumulated qualifying costs transfer from project accounts to anläggningsregister
2. The asset follows normal avskrivning schedules
```
Completion entry:
Debet 1010/1020 (materiell/immateriell tillgång) 500,000
Kredit 1470 {6 "P001"} OR 38xx {6 "P001"} 500,000
```
### K2 vs K3 constraints
- K3: both materiella and immateriella egenupparbetade tillgångar can be activated
- K2: only förvärvade immateriella tillgångar; egenupparbetade must be expensed
- Project-specific tangible assets (e.g., construction equipment bought for a project) follow normal asset rules regardless of K2/K3
---
## 9. Period-end procedures
At each period close, the engine should execute per-project:
1. **Update färdigställandegrad**: recalculate based on latest cost data
2. **Adjust upparbetad intäkt**: book changes to 1620/2450 (successiv vinstavräkning) or 1470/4970 (alternativregeln)
3. **Assess cost accruals**: book upplupna kostnader (2990) for work received but not yet invoiced by suppliers
4. **Review prepaid costs**: verify kontogrupp 17xx items still relate to future project work
5. **Evaluate befarade förluster**: compare estimated total cost to contract revenue; if loss expected, book provision immediately
6. **Calculate tax divergence**: for löpande räkning projects, compute difference between accounting and tax revenue
7. **Run overhead allocation**: distribute shared costs using configured fördelningsnycklar
### Automated checks
The engine should flag:
- Projects where färdigställandegrad > 90% but status is still ACTIVE (prompt for completion review)
- Projects where actual costs > 110% of budget (trigger cost review)
- Projects with no time entries in last 30 days but status ACTIVE (dormant project)
- Projects with 1620/2450/1470 balances but status CLOSED (incomplete closing)
- Projects approaching end_date with significant remaining work
---
## 10. Validation and error detection
### Transaction-level validation
Before saving a journal entry line with a project tag:
1. Verify project status is ACTIVE
2. Check account_dimension_settings: if MANDATORY for this account, project_id must be set
3. Check account_dimension_settings: if NOT_ALLOWED for this account, project_id must be null
4. Verify project's cost_center matches the line's cost_center (if both set and company enforces hierarchy)
5. For construction projects: check reverse_charge flag consistency with VAT account used
### Period-level validation
At period close:
1. **Orphaned costs**: identify transactions on project-mandatory accounts lacking project tags
2. **Closed project entries**: detect transactions booked to closed projects
3. **Balance sheet consistency**: verify 1620+1470 totals match subsidiary project ledger
4. **Cross-project netting**: ensure no netting between projects on 1620/2450/1470 (Srf U 14 violation)
5. **Moms consistency**: verify WIP adjustments (1620/2450 entries) carry no moms amounts
6. **Befarad förlust coverage**: all projects with estimated loss have corresponding provision
### SIE export validation
Before generating SIE4 export:
1. All projects with transactions have corresponding #OBJEKT declarations
2. Object numbers match between #OBJEKT and #TRANS records
3. Multi-year projects carry correct #OIB/#OUB balances
4. Dimension 6 is declared if any project objects exist
5. No transaction line references both a project and a cost center from different organizational units (if hierarchy enforced)
@@ -0,0 +1,228 @@
# K2/K3 Revenue Recognition for Project Accounting
## Table of contents
1. K3 Chapter 23: successiv vinstavräkning
2. K2 Chapter 6: uppdrag till fast pris
3. K2 vs K3 comparison
4. Befarade förluster
5. Note disclosures
6. Choosing K2 vs K3 for project-intensive companies
---
## 1. K3 Chapter 23: successiv vinstavräkning
BFNAR 2012:1 Chapter 23 ("Intäkter") governs revenue recognition for tjänsteuppdrag and entreprenadavtal.
### The mandatory main rule (punkt 23.18)
For koncernredovisning: successiv vinstavräkning is MANDATORY for fixed-price contracts.
For juridisk person: successiv vinstavräkning is the default, with an opt-out to alternativregeln per punkt 23.31.
Revenue and expenses shall be recognized based on the färdigställandegrad at each balance sheet date when ALL FOUR conditions are met:
1. Uppdragsinkomsten (contract revenue) can be reliably measured
2. It is probable that economic benefits will flow to the entity
3. Färdigställandegrad at balance date can be reliably determined
4. Uppdragsutgifter incurred and remaining can be reliably measured
If conditions are NOT met: recognize revenue only to the extent of costs incurred (zero profit method) until reliable estimation becomes possible.
### Calculating färdigställandegrad
The cost-to-cost method is dominant in Swedish practice:
```
Färdigställandegrad = Nedlagda kostnader / Totalt beräknade kostnader
```
Other permitted methods (less common):
- Physical measurement of work completed
- Proportion of contract work completed (milestones)
What counts as nedlagda kostnader:
- Direct materials consumed
- Direct labor (timkostnad × hours from time reporting)
- Subcontractor costs
- Directly attributable overhead (allocated per fördelningsnyckel)
- Depreciation on project-specific equipment
What does NOT count:
- Costs for future activity (materials delivered but not installed)
- Prepaid costs not yet consumed
- General overhead not attributable to the project
### Löpande räkning (time-and-materials)
Punkt 23.17: revenue recognized as work is performed. No completion percentage calculation needed. The practical rule: revenue = hours worked × agreed hourly rate + materials at agreed markup.
WIP for löpande räkning: upparbetad ej fakturerad = (work performed at agreed rates) - (invoiced amount). If positive: Debet 1620L / Kredit 3xxx. This is straightforward because the "reliable estimation" conditions are inherently met.
### Alternativregeln in juridisk person (punkt 23.31)
Available only for pågående arbeten in byggnads-, anläggnings-, hantverks- eller konsultrörelse, as referenced to 17 kap. 23 § IL.
Under this method:
- Costs are capitalized on 1470
- Revenue recognized only when the project is substantially complete
- Requires "särskilda skäl" to switch from successiv vinstavräkning (punkt 23.37)
- Once chosen, must be applied consistently to similar contracts
### Contract modifications and claims
Punkt 23.20-23.22: contract modifications (tilläggsarbeten) are included in contract revenue when:
- The customer has approved the modification (formally or through actions)
- The amount can be reliably measured
Incentive payments and claims: included only when the project has progressed far enough that performance targets are probable and measurable.
### Multi-element contracts
Punkt 23.9-23.15: a single contract may be split into separate assignments if each has separate pricing and can be accepted/rejected independently. Conversely, multiple contracts with the same customer should be combined if negotiated as a package and performed concurrently/sequentially.
---
## 2. K2 Chapter 6: uppdrag till fast pris
BFNAR 2016:10 Chapter 6 provides two methods for fixed-price contracts.
### Huvudregeln (punkt 6.14)
Similar to successiv vinstavräkning. Revenue recognized based on degree of completion. Same cost-to-cost calculation applies.
### Alternativregeln (punkt 6.15)
Revenue recognized only when the assignment is "väsentligen fullgjort" (substantially complete).
Per Srf U 15: "väsentligen fullgjort" is assessed from the customer's acceptance and realization perspective, not a volume perspective. The work must be in a state where the customer can derive the intended benefit. Remaining work should be minor corrections, documentation, or warranty-related tasks.
Per Srf U 14: pågående arbeten under alternativregeln must be reported gross per individual project. Netting of projects with surplus (1470) against projects with deficit (liability on 2430-2439) is prohibited.
### Löpande räkning under K2
Punkt 6.12-6.13: revenue recognized as work is performed, identical to K3. The special K2 provision: if a time-and-materials contract includes a price ceiling (takpris), it is still treated as löpande räkning unless the ceiling fundamentally changes the risk profile.
---
## 3. K2 vs K3 comparison for project accounting
| Aspect | K2 | K3 |
|--------|----|----|
| Fixed-price recognition | Main rule OR alternativregeln | Successiv vinstavräkning mandatory (koncern) |
| T&M recognition | As work performed | As work performed |
| Dev cost capitalization | Prohibited (punkt 10.4) | Allowed under aktiveringsmodellen |
| Uppskjuten skatt | Prohibited | Required |
| Revenue recognition standard | Rule-based, simplified | Principle-based (Chapter 23) |
| Resultaträkning format | Kostnadsslagsindelning only | Kostnadsslags- or funktionsindelning |
| Note disclosures | Simplified | Full disclosures per Chapter 23 |
| Multi-element splitting | Limited guidance | Detailed rules (punkt 23.9-23.15) |
| Befarade förluster | Must recognize immediately | Must recognize immediately |
### Key K2 limitation: punkt 10.4
Egenupparbetade immateriella tillgångar får inte aktiveras. ALL internally generated intangible assets (software, patents, development work) must be expensed immediately. Even a partially purchased asset becomes "tainted" as internally generated if incorporated into new development. Companies needing to capitalize development costs MUST use K3.
This is the single most impactful difference for project-intensive tech/R&D companies. A software company spending 2 MSEK/year on development cannot show this as an asset under K2, directly reducing equity and potentially triggering kontrollbalansräkning.
---
## 4. Befarade förluster
### K3 requirement (punkt 23.32)
If total estimated contract costs exceed total contract revenue, the expected loss must be recognized as a cost IMMEDIATELY, regardless of:
- How far the project has progressed
- Whether work has even begun
- The färdigställandegrad
The full expected loss is recognized, not just the loss proportional to completion.
### K2 requirement
Same principle applies under both huvudregeln and alternativregeln. An anticipated loss must be expensed in full when identified.
### Detection logic for software implementation
Flag a project for befarad förlust review when:
```
IF (actual_costs_to_date + estimated_remaining_costs) > total_contract_revenue
THEN expected_loss = total_estimated_costs - total_contract_revenue
provision_needed = expected_loss - any_existing_provision
```
The system should alert when:
1. Estimated remaining costs are updated upward
2. Contract revenue is revised downward
3. Actual costs exceed the original budget pace by >15%
4. Färdigställandegrad lags behind the time-elapsed ratio by >25%
### Booking pattern
```
Debet 4970 / 78xx {6 "project"} [loss amount]
Kredit 2290 {6 "project"} [loss amount]
```
If the loss estimate later decreases, the provision is reversed (but never below zero, and never creating a profit provision).
---
## 5. Note disclosures
### K3 större företag (punkter 23.28-23.30)
The årsredovisning must include notes disclosing:
1. Revenue recognized from construction/service contracts in the period
2. Methods used to determine färdigställandegrad
3. Aggregate amount of costs incurred and recognized profits (less recognized losses) to date
4. Amount of advances received
5. Amount of retentions (innehållna belopp)
6. Gross amount due from customers (tillgång): costs + recognized profits - recognized losses - progress billings, when positive
7. Gross amount due to customers (skuld): same calculation, when negative
### K3 mindre företag
Simplified disclosures. Must disclose:
- Accounting principles for revenue recognition
- Principles for pågående arbeten
### K2
Chapter 18 and 19 note requirements. Must disclose:
- Redovisningsprinciper including method for pågående arbeten
- Whether huvudregeln or alternativregeln is applied
### Balance sheet presentation
Under ÅRL:
- 1620 (upparbetad ej fakturerad intäkt): presented under Kortfristiga fordringar
- 1470 (pågående arbeten): presented under Varulager > Pågående arbete för annans räkning
- 2450 (fakturerad ej upparbetad intäkt): presented under Kortfristiga skulder
---
## 6. Choosing K2 vs K3 for project-intensive companies
### Factors favoring K3
- Need to capitalize development costs (software, R&D)
- Large fixed-price construction contracts spanning multiple years (successiv vinstavräkning gives smoother P&L)
- Want to present both kostnadsslags- and funktionsindelad resultaträkning
- Revenue recognition complexity requires principle-based framework
- Audit requirement makes K3 transition cost marginal
### Factors favoring K2
- Simple project structure (few projects, all löpande räkning)
- No internally generated intangible assets
- Preference for the alternativregeln's simplicity (recognize at completion)
- No audit requirement, small company wanting minimal compliance burden
- Construction company preferring to defer revenue recognition for tax timing
### Tax implications of the choice
Under the materiellt samband principle, accounting choices flow to taxation. A K3 company using successiv vinstavräkning recognizes taxable income earlier than a K2 company using alternativregeln on the same project. However, the alternativregeln provides tax deferral (matched by delayed cost relief via 1470 capitalization), which can be a significant cash flow benefit for growing construction companies.
The exception: löpande räkning contracts where tax treatment can diverge from accounting (HFD 2011 ref. 20). See tax-and-grants.md for details.
@@ -0,0 +1,260 @@
# SIE4 Project Dimensions
## Table of contents
1. Dimension system overview
2. Record types for dimensions
3. Transaction encoding with objects
4. Object balances and budgets
5. Import/export considerations
6. Mapping to Fortnox/Visma/Bokio
---
## 1. Dimension system overview
SIE4 (Version 4B, 2008-09-30) provides a standardized encoding of dimensional data through reserved and user-defined dimensions.
### Reserved dimensions
| Dim # | Name | Multi-year | Notes |
|-------|------|-----------|-------|
| 1 | Kostnadsställe/Resultatenhet | No (resets annually) | Most commonly used |
| 2 | Kostnadsbärare | No | Sub-dimension of 1 (declared with #UNDERDIM) |
| 3-5 | Reserved | - | Not used in practice |
| 6 | Projekt | Yes (accumulates across years) | Second most common |
| 7 | Anställd | - | Employee tracking |
| 8 | Kund | - | Customer tracking |
| 9 | Leverantör | - | Supplier tracking |
| 10 | Faktura | - | Invoice tracking |
| 11-19 | Reserved | - | For future use |
| 20+ | Freely available | User-defined | Custom dimensions |
Dimensions using reserved numbers (1-19) need not be declared with #DIM. Non-reserved dimensions (20+) must be explicitly declared.
---
## 2. Record types for dimensions
### #DIM -- Declare a dimension
Syntax: `#DIM dimension_no "name"`
```
#DIM 6 "Projekt"
#DIM 1 "Kostnadsställe"
```
Reserved dimensions don't need this declaration but it is recommended for clarity.
### #UNDERDIM -- Declare a sub-dimension
Syntax: `#UNDERDIM dimension_no "name" parent_dimension_no`
```
#UNDERDIM 2 "Kostnadsbärare" 1
```
Dimension 2 is a sub-dimension of dimension 1. Each object in dimension 2 belongs to exactly one object in dimension 1.
### #OBJEKT -- Declare an object within a dimension
Syntax: `#OBJEKT dimension_no "object_no" "object_name"`
```
#OBJEKT 6 "P2026-001" "Customer Project Alpha"
#OBJEKT 6 "P2026-002" "Internal R&D Sprint 4"
#OBJEKT 1 "100" "Stockholm Office"
#OBJEKT 1 "200" "Gothenburg Branch"
```
Object numbers are strings (quoted). They can contain alphanumeric characters and hyphens. Maximum length varies by importing system but 20 characters is safe.
---
## 3. Transaction encoding with objects
### #TRANS with object list
Objects are attached to transaction lines via an object list in braces `{}`. The object list contains pairs of `dimension_no "object_no"`.
Syntax: `#TRANS account_no {dim_no "obj_no" dim_no "obj_no" ...} amount [transdate] [transtext] [quantity] [sign]`
### Examples
Single project tag:
```
#TRANS 3041 {6 "P2026-001"} -100000.00
```
Multiple dimensions (project + cost center):
```
#TRANS 3041 {1 "100" 6 "P2026-001"} -100000.00
```
No dimensional tags (empty braces):
```
#TRANS 1510 {} 125000.00
```
### Complete verification example
A consulting invoice for 100,000 SEK (25% moms) on project P2026-001, cost center 100:
```
#VER A 101 20260115 "Konsultarvode Projekt Alpha"
{
#TRANS 1510 {} 125000.00
#TRANS 2611 {} -25000.00
#TRANS 3041 {1 "100" 6 "P2026-001"} -100000.00
}
```
The receivable (1510) and VAT liability (2611) carry no project tags. Only the revenue account (3041) is dimensionally tagged.
### WIP adjustment example (successiv vinstavräkning)
```
#VER E 45 20261231 "Upparbetad ej fakturerad Q4"
{
#TRANS 1620 {6 "P2026-001"} 200000.00
#TRANS 3041 {6 "P2026-001"} -200000.00
}
```
### WIP capitalization example (alternativregeln)
```
#VER E 46 20261231 "Pågående arbete period 12"
{
#TRANS 1470 {6 "P2026-003"} 350000.00
#TRANS 4970 {6 "P2026-003"} -350000.00
}
```
---
## 4. Object balances and budgets
### #OIB -- Object opening balance
Syntax: `#OIB year_index account_no {dim_no "obj_no"} amount`
```
#OIB 0 1470 {6 "P2026-001"} 450000.00
#OIB 0 1620 {6 "P2026-002"} 85000.00
```
Year index 0 = current year, -1 = previous year.
### #OUB -- Object closing balance
Syntax: `#OUB year_index account_no {dim_no "obj_no"} amount`
```
#OUB 0 1470 {6 "P2026-001"} 580000.00
```
### #PSALDO -- Period object balance
Syntax: `#PSALDO year_index period account_no {dim_no "obj_no"} amount`
```
#PSALDO 0 202601 4010 {6 "P2026-001"} 49655.00
#PSALDO 0 202601 3041 {6 "P2026-001"} -125000.00
```
### #PBUDGET -- Period object budget
Syntax: `#PBUDGET year_index period account_no {dim_no "obj_no"} amount`
```
#PBUDGET 0 202601 4010 {6 "P2026-001"} 55000.00
```
Budget data enables budget vs actual comparison per project.
---
## 5. Import/export considerations
### SIE Type 4E (export, .SE files)
- All verifications with full object specifications are included
- #DIM and #OBJEKT declarations are recommended
- #KONTO declarations should cover all accounts used
- Objects referenced in transactions should all have corresponding #OBJEKT records
### SIE Type 4I (import, .SI files)
- Declarations are optional but recommended
- Transactions referencing undeclared objects: behavior varies by system
- Fortnox: auto-creates the object
- Visma: rejects the import with an error
- BL Administration: prompts the user
- Best practice: always include #OBJEKT declarations for all referenced objects
### Encoding considerations
SIE files use CP437 encoding by default (#FLAGGA 0). UTF-8 is indicated by #FLAGGA 1. Project names with å/ä/ö must be encoded correctly. See the swedish-sie-import-export skill for encoding details.
### Multi-dimensional transactions
A single #TRANS line can carry objects from multiple dimensions simultaneously. The object list is unordered (dimension numbers identify which dimension each object belongs to). Parser implementation should handle:
- Zero objects: `{}`
- Single dimension: `{6 "P001"}`
- Multiple dimensions: `{1 "100" 6 "P001"}`
- Same dimension appearing only once (duplicates are invalid)
---
## 6. Mapping to Fortnox/Visma/Bokio
### Fortnox
Fortnox exposes projects via `/3/projects/{ProjectNumber}` in its API.
Project properties:
- ProjectNumber (string, user-assigned)
- Description
- Status: ONGOING or COMPLETED
- StartDate, EndDate
- ProjectLeader
- ContactPerson
Voucher rows carry independent `Project` and `CostCenter` string fields. Both are optional per row. The VoucherRow object also has `CostCenterSettings` and `ProjectSettings` properties per account: ALLOWED, MANDATORY, or NOT_ALLOWED.
SIE mapping: Project → dimension 6, CostCenter → dimension 1. Direct and unambiguous.
Limitation: voucher search in Fortnox API only indexes header-level project tags, not row-level. A voucher with project P001 on individual rows but no header-level project will not appear in project-filtered searches.
### Visma
Visma uses renamable Objekt 1 and Objekt 2 types. The **Flerårigt** flag determines behavior:
- Flerårigt = true: balances accumulate across fiscal years (project behavior)
- Flerårigt = false: balances reset annually (cost center behavior)
Visma.net adds **project balancing**: objects marked "Balanserat" get income/expense rebooked to balance accounts at year-end. At project close, a "resultatföring" operation transfers accumulated balance to the real P&L.
SIE mapping: Flerårigt objects → dimension 6, non-Flerårigt → dimension 1. The mapping must respect the Flerårigt flag during SIE export.
### Bokio
Bokio uses unlimited **Tags** organized in **Tag Groups** functioning as dimensions. Users can create groups like "Projekt", "Avdelning", "Region" etc.
Key differences:
- A single transaction row can be split across multiple tags by percentage
- Tags are a reporting overlay; they do NOT export to SIE files as standard dimensions
- No built-in WIP accounting or revenue recognition logic
SIE mapping: no direct mapping. If importing Bokio data via SIE and the source had project tags, they are lost. Conversely, SIE imports into Bokio will not carry dimensional data into the tag system.
### Data model implication
For an AI-native bookkeeping engine supporting SIE interchange:
- Use SIE dimension numbers (1 for KS, 6 for project) as the canonical dimension identifiers
- Store object codes as strings (matching SIE format)
- Support per-account dimension enforcement (ALLOWED/MANDATORY/NOT_ALLOWED)
- Maintain multi-year balance accumulation for dimension 6 objects
- Reset dimension 1 balances at fiscal year boundary (unless explicitly overridden)
@@ -0,0 +1,236 @@
# Tax Implications and Grant Accounting for Projects
## Table of contents
1. Materiellt samband principle
2. Tax treatment of pågående arbeten
3. Löpande räkning tax divergence
4. Forskningsavdrag (R&D payroll deduction)
5. Aktivering av utvecklingsutgifter
6. EU grants and offentliga bidrag
7. Omvänd skattskyldighet in construction
---
## 1. Materiellt samband principle
Under 14 kap. IL (Inkomstskattelagen), the reported accounting result is the starting point for taxable income. Revenue recognition choices in the financial accounts flow directly to taxation. This means:
- A K3 company using successiv vinstavräkning recognizes taxable income earlier (as completion progresses)
- A K2 company using alternativregeln defers taxable income until project completion
- Switching between methods has immediate tax consequences
- Consistency is required: the same method must be used for both accounting and tax on identical contract types
The tax authority (Skatteverket) follows the accounting treatment unless specific tax provisions override it.
---
## 2. Tax treatment of pågående arbeten
### Fixed-price contracts (uppdrag till fast pris)
17 kap. 24-32 §§ IL govern WIP valuation for tax purposes.
Under the alternativregeln, pågående arbeten (1470) may be valued at:
- Direct costs (direkt hänförliga utgifter)
- With the option to apply the 97% rule (17 kap. 4 § IL): value WIP at 97% of anskaffningsvärde, providing a small tax buffer
Anti-avoidance provision (17 kap. 31 § IL): revenue must be recognized when invoicing has been significantly delayed beyond god affärssed. A company cannot indefinitely defer revenue by simply not invoicing a completed project.
Under successiv vinstavräkning, the accounting-based revenue recognition flows directly to tax via the materiellt samband. No separate tax adjustment is needed for fixed-price contracts.
### INK2 adjustments
When accounting and tax treatment diverge (see section 3), adjustments are made in the inkomstdeklaration:
- INK2 ruta 4.3 c: Bokfört resultat justeras med redovisade ej skattepliktiga intäkter
- INK2 ruta 4.5: Skattemässiga justeringar av intäkter och kostnader
The engine should track which revenue items require INK2 adjustment per project.
---
## 3. Löpande räkning tax divergence
This is the most critical tax-accounting divergence in project accounting.
### The rule
For time-and-materials contracts (löpande räkning), taxation is based on **invoiced amounts**, even when accounting applies successiv vinstavräkning and recognizes upparbetad ej fakturerad intäkt.
Legal basis: HFD rulings (RÅ 2006 ref. 28, HFD 2011 ref. 20) established that:
- Upparbetad ej fakturerad intäkt (1620) on löpande räkning contracts is NOT taxable
- Only invoiced amounts create skattemässig intäkt
- This applies regardless of which accounting method is used
### Practical consequence
The bookkeeping engine must maintain DUAL revenue figures for every T&M project:
1. **Accounting revenue**: upparbetad intäkt (hours × rate, reflecting work performed)
2. **Tax revenue**: fakturerad intäkt (amounts on issued invoices)
The difference creates a temporary tax difference:
- If accounting revenue > invoiced: positive temporary difference → uppskjuten skatteskuld (K3 only; K2 prohibits uppskjuten skatt)
- If invoiced > accounting revenue: negative temporary difference → uppskjuten skattefordran
### INK2 handling
At year-end, the difference between accounting and tax revenue for T&M projects must be reported as an adjustment in INK2. The engine should automatically calculate this as: `Σ(accounting_revenue - invoiced_revenue)` across all löpande räkning projects.
---
## 4. Forskningsavdrag (R&D payroll deduction)
### Current rules (Lag 2023:747)
Companies employing personnel working on R&D (forskning eller utveckling) receive a 20% reduction in arbetsgivaravgifter.
Requirements per employee:
- ≥50% of working time spent on qualifying R&D activities
- ≥15 hours per month on R&D
Cap: SEK 3,000,000 per month per group (koncern). At 31.42% arbetsgivaravgifter, this allows deduction on salary costs up to approximately 9.5 MSEK/month.
### Project accounting connection
Project-level time tracking is ESSENTIAL for forskningsavdrag compliance:
- Time reports must distinguish R&D hours from non-R&D hours per employee
- Projects must be classified as R&D or non-R&D
- Monthly time summaries per employee must demonstrate the 50%/15h thresholds
- Documentation must be retained for Skatteverket review
The engine should:
1. Allow project-level R&D classification
2. Aggregate employee time across R&D-classified projects
3. Calculate monthly R&D percentage per employee
4. Flag employees meeting/not meeting the threshold
5. Calculate the deduction amount for arbetsgivardeklaration
### Proposed reform (SOU 2025:3)
A reform proposal would replace the payroll deduction with either:
- A 300% cost deduction for qualifying R&D expenses, OR
- A 20% refundable tax credit (skattereduktion)
If adopted, project-level R&D cost tracking (not just time) would become the basis. The engine should be prepared to track total project costs for R&D-classified projects.
---
## 5. Aktivering av utvecklingsutgifter
### K3 Chapter 18 (immateriella tillgångar)
Companies may choose between:
- **Aktiveringsmodellen**: capitalize qualifying development costs as intangible assets
- **Kostnadsföringsmodellen**: expense all development costs immediately
Once chosen, the model applies to all development activities (no cherry-picking).
### Activation criteria (punkt 18.12)
ALL SIX conditions must be demonstrated:
1. Technical feasibility of completing the asset
2. Intention to complete and use/sell
3. Ability to use or sell the asset
4. How the asset will generate probable future economic benefits
5. Availability of adequate resources (technical, financial) to complete
6. Ability to reliably measure expenditure attributable to the asset during development
Research-phase costs (punkt 18.11) must ALWAYS be expensed, regardless of model choice.
### Project accounting for activation
The project serves as the cost collector during the development phase:
1. Classify project as "development" or "research" (or mixed, requiring phase separation)
2. Accumulate qualifying costs on the project: direct salary, materials, directly attributable overhead
3. At period end, capitalize accumulated costs: Debet 10xx (immateriell tillgång) / Kredit 38xx (aktiverat arbete)
4. Transfer equivalent amount to fond för utvecklingsutgifter (restricted equity per ÅRL 4 kap. 2 §)
5. Begin amortization when the asset is ready for use (max 10 years per K3 18.18, default 5 years per ÅRL 4 kap. 4 §)
### Tax treatment
Since 2019: interest costs may NOT be included in the tax-basis anskaffningsvärde, even when K3 allows their inclusion in accounting. The engine must track a separate tax-basis value excluding capitalized interest.
Skatteverket position: aktivering following K3 creates a materiellt samband. The capitalized amount is tax-deductible through annual avskrivning, not at the time of expenditure. The fund för utvecklingsutgifter has no tax effect.
### K2 prohibition
Punkt 10.4: egenupparbetade immateriella tillgångar får inte aktiveras. Everything must be expensed. A partially purchased asset becomes tainted as internally generated if incorporated into unique development. This is a hard constraint with no exceptions.
---
## 6. EU grants and offentliga bidrag
### K3 Chapter 24
Offentliga bidrag are recognized based on performance conditions:
- **Grants with future performance requirements**: recognized as revenue when performance conditions are satisfied
- **Grants without conditions**: recognized as revenue when received
- **Grants related to assets**: reduce the carrying amount of the asset OR recognized as intäkt over the asset's useful life
### BAS accounts
| Account | Purpose |
|---------|---------|
| 3980 | Erhållna statliga bidrag |
| 3981 | Erhållna EU-bidrag |
| 3989 | Övriga erhållna bidrag |
| 2880 | Skuld offentliga bidrag (liability for unearned grants) |
| 1790 | Övriga fordringar (approved but unreceived grants) |
### Project accounting for grants
Grant-funded projects require tracking:
1. Total granted amount and disbursement schedule
2. Eligible costs (per grant agreement, often narrower than accounting costs)
3. Co-financing requirements (own contribution percentage)
4. Reporting periods and deadlines
5. Repayment risk if conditions are not met
The engine should support:
- Grant budget vs actual tracking per project
- Eligible cost filtering (which cost categories qualify)
- Period reporting aligned to grant reporting periods (often different from fiscal year)
- Automatic matching of received payments to 2880 liability releases
### VAT on grants
Grants are generally not subject to VAT (not consideration for a supply). However, if a grant is conditional on delivering specific goods/services to the grantor, it may be reclassified as consideration and become subject to moms. The classification affects both the VAT treatment and the BAS account used (3xxx vs 39xx).
---
## 7. Omvänd skattskyldighet in construction
### When it applies
Reverse charge VAT applies in the construction sector when:
1. The seller provides byggtjänster (construction services)
2. The buyer is a company that itself "more than temporarily" provides construction services
### Covered services
- Bygg- och anläggningsarbeten
- Bygginstallationer (VVS, el, ventilation)
- Slutbehandling av byggnader (målning, golvläggning)
- Rivningsarbeten
- Uthyrning av personal för byggtjänster
- Uthyrning av maskiner med förare för bygg
### NOT covered
- Arkitektverksamhet
- Byggkonsultverksamhet (engineering, project management)
- Projektledning (unless physically performing construction)
- Leverans av byggmaterial utan installation
- Städning (unless part of a construction contract)
### Project accounting impact
Each project must track whether reverse charge applies, and this can vary within a single project if there are mixed services. The engine must:
1. Per-project (or per-transaction) reverse charge flag
2. Seller perspective: invoice utan moms, buyer self-assesses
3. Buyer perspective: report both utgående and ingående moms (BAS 2614/2645 for 25%)
4. Momsdeklaration: reverse charge amounts in ruta 24 (byggtjänster) and ruta 30 (ingående moms)
A construction project may have some subcontractors subject to reverse charge and others not (e.g., an architect is not subject to reverse charge). Per-line handling is required.
@@ -753,6 +753,7 @@ function OptionsStep({
options,
sieAvailable,
sieData,
provider,
onChange,
onStart,
onBack,
@@ -760,6 +761,7 @@ function OptionsStep({
options: MigrationOptions
sieAvailable: boolean
sieData: SIEData | null
provider: ArcimProvider | null
onChange: (options: MigrationOptions) => void
onStart: () => void
onBack: () => void
@@ -881,7 +883,9 @@ function OptionsStep({
<OptionRow
icon={<FileText className="h-4 w-4" />}
label="Leverantörsfakturor"
description="Alla leverantörsfakturor (betalda och obetalda)"
description={provider === 'fortnox'
? 'Endast obetalda leverantörsfakturor hämtas. Historiska betalda fakturor finns kvar i Fortnox.'
: 'Alla leverantörsfakturor (betalda och obetalda)'}
checked={options.importSupplierInvoices}
onChange={() => toggleOption('importSupplierInvoices')}
/>
@@ -2006,6 +2010,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
options={migrationOptions}
sieAvailable={preview?.sieAvailable ?? false}
sieData={sieData}
provider={preview?.consent.provider ?? null}
onChange={setMigrationOptions}
onStart={handleStartMigration}
onBack={() => preview?.sieAvailable ? setStep('mapping') : setStep('preview')}
+171
View File
@@ -0,0 +1,171 @@
/**
* Processing history (behandlingshistorik) — append helper.
*
* Appends events to the processing_history table within the caller's
* database transaction. Throws on failure so that the table writes
* and the audit trail are atomically consistent.
*
* PII BOUNDARY: payload MUST contain pseudonymous IDs only (user UUIDs,
* company UUIDs, counterparty IDs). Never names, emails, personnummer,
* addresses, or phone numbers. These live in their source tables (profiles,
* customers, suppliers) and are referenced by ID. GDPR erasure pseudonymizes
* the source tables; processing_history events become undecipherable by
* reference, which is the required behavior per v0.2 §10.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
ProcessingHistoryAggregateType,
ProcessingHistoryActor,
} from '@/types'
import { z } from 'zod'
// ── PII validator ───────────────────────────────────────────────
// Rejects payloads containing Swedish personal identity numbers.
// Personnummer: YYMMDD-NNNN or YYMMDDNNNN (6+4 digits)
// Samordningsnummer: Same format but day +60
// Organisationsnummer: NNNNNN-NNNN (10 digits, but we catch the pattern)
// Word boundaries prevent false positives on Bankgiro (123456-7890) and
// invoice references like 202312-1234 that share the digit shape but aren't PII.
const PII_PATTERNS = [
/\b\d{6}-?\d{4}\b/, // personnummer, samordningsnummer
/\b\d{8}-?\d{4}\b/, // 12-digit variant (YYYYMMDD-NNNN) or orgnr
]
function containsPii(value: unknown): boolean {
if (typeof value === 'string') {
return PII_PATTERNS.some(pattern => pattern.test(value))
}
if (Array.isArray(value)) {
return value.some(containsPii)
}
if (value !== null && typeof value === 'object') {
return Object.values(value).some(containsPii)
}
return false
}
const piiSafePayload = z.record(z.string(), z.unknown()).refine(
(payload) => !containsPii(payload),
{ message: 'Payload contains PII (personnummer/samordningsnummer/orgnr pattern). Use pseudonymous IDs only.' }
)
function assertActorPiiSafe(actor: ProcessingHistoryActor): void {
if (actor.label && PII_PATTERNS.some(p => p.test(actor.label!))) {
throw new Error(
'actor.label contains PII (personnummer/samordningsnummer/orgnr pattern). Use a pseudonymous descriptor only.'
)
}
}
// ── Input type ──────────────────────────────────────────────────
export interface AppendEventInput {
companyId: string
correlationId: string
causationId?: string
aggregateType: ProcessingHistoryAggregateType
aggregateId: string
eventType: string
payload: Record<string, unknown>
payloadSchemaVersion?: number
actor: ProcessingHistoryActor
rubricVersion?: string
occurredAt: Date // mandatory — no default. Caller must set explicitly.
}
// ── Append functions ────────────────────────────────────────────
/**
* Append a single event to processing_history within the caller's transaction.
*
* Uses the provided SupabaseClient (which should be the same client used for
* table writes in the command handler). Throws on failure so that both the
* table writes and the audit trail roll back together.
*
* Returns the generated event_id (pre-generated client-side for causation chaining).
*/
export async function appendProcessingHistory(
supabase: SupabaseClient,
input: AppendEventInput
): Promise<string> {
// Validate payload + actor.label contain no PII
piiSafePayload.parse(input.payload)
assertActorPiiSafe(input.actor)
const eventId = crypto.randomUUID()
const { error } = await supabase
.from('processing_history')
.insert({
event_id: eventId,
company_id: input.companyId,
correlation_id: input.correlationId,
causation_id: input.causationId ?? null,
aggregate_type: input.aggregateType,
aggregate_id: input.aggregateId,
event_type: input.eventType,
payload: input.payload,
payload_schema_version: input.payloadSchemaVersion ?? 1,
actor: input.actor,
rubric_version: input.rubricVersion ?? null,
occurred_at: input.occurredAt.toISOString(),
})
if (error) {
throw new Error(
`Failed to append processing_history event ${input.eventType}: ${error.message}`
)
}
return eventId
}
/**
* Append multiple events atomically within the caller's transaction.
* Used for batch operations (e.g., migration commits, multi-event command handlers).
*
* Returns array of generated event_ids in input order.
*/
export async function appendProcessingHistoryBatch(
supabase: SupabaseClient,
inputs: AppendEventInput[]
): Promise<string[]> {
if (inputs.length === 0) return []
const eventIds = inputs.map(() => crypto.randomUUID())
// Validate all payloads + actor labels before any DB write
for (const input of inputs) {
piiSafePayload.parse(input.payload)
assertActorPiiSafe(input.actor)
}
const rows = inputs.map((input, i) => ({
event_id: eventIds[i],
company_id: input.companyId,
correlation_id: input.correlationId,
causation_id: input.causationId ?? null,
aggregate_type: input.aggregateType,
aggregate_id: input.aggregateId,
event_type: input.eventType,
payload: input.payload,
payload_schema_version: input.payloadSchemaVersion ?? 1,
actor: input.actor,
rubric_version: input.rubricVersion ?? null,
occurred_at: input.occurredAt.toISOString(),
}))
const { error } = await supabase
.from('processing_history')
.insert(rows)
if (error) {
throw new Error(
`Failed to append processing_history batch (${inputs.length} events): ${error.message}`
)
}
return eventIds
}
+4 -1
View File
@@ -26,7 +26,10 @@ export const FORTNOX_RESOURCE_CONFIGS: Partial<Record<ResourceType, FortnoxResou
supportsLastModified: true,
},
[ResourceType.SupplierInvoices]: {
listEndpoint: '/supplierinvoices',
// Only fetch unpaid/open supplier invoices. Historic paid invoices add
// noise and Fortnox's list endpoint doesn't reliably expose FullyPaid,
// which caused paid invoices to be imported as unpaid.
listEndpoint: '/supplierinvoices?filter=unpaid',
listKey: 'SupplierInvoices',
detailEndpoint: '/supplierinvoices/{id}',
detailKey: 'SupplierInvoice',
+33 -2
View File
@@ -69,19 +69,50 @@ describe('getOpeningBalances', () => {
opening_balance_entry_id: null,
}
it('computes balances from all prior entries', async () => {
it('carries forward balance sheet accounts (class 1-2) only', async () => {
mockFetchAllRows.mockResolvedValue([
{ account_number: '1930', debit_amount: 100000, credit_amount: 5000 },
{ account_number: '2440', debit_amount: 0, credit_amount: 25000 },
// P&L accounts (class 3-8) must NOT carry forward — they reset
// to zero each fiscal year via årets resultat.
{ account_number: '3001', debit_amount: 0, credit_amount: 80000 },
{ account_number: '5410', debit_amount: 12000, credit_amount: 0 },
{ account_number: '8310', debit_amount: 0, credit_amount: 1500 },
])
const { balances, obEntryId } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.get('1930')).toEqual({ debit: 100000, credit: 5000 })
expect(balances.get('3001')).toEqual({ debit: 0, credit: 80000 })
expect(balances.get('2440')).toEqual({ debit: 0, credit: 25000 })
expect(balances.has('3001')).toBe(false)
expect(balances.has('5410')).toBe(false)
expect(balances.has('8310')).toBe(false)
expect(obEntryId).toBeNull()
})
it('does not accumulate P&L across multi-year SIE imports', async () => {
// Simulates importing SIE files for 2022 and 2023, then opening 2024:
// BS movements over both years should net to a single IB; P&L from
// both years must be discarded.
mockFetchAllRows.mockResolvedValue([
// 2022 IB + activity on a BS account
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 30000, credit_amount: 10000 },
// 2023 activity on the same BS account
{ account_number: '1930', debit_amount: 20000, credit_amount: 5000 },
// 2022 + 2023 P&L activity that previously accumulated incorrectly
{ account_number: '3001', debit_amount: 0, credit_amount: 200000 },
{ account_number: '3001', debit_amount: 0, credit_amount: 250000 },
{ account_number: '5410', debit_amount: 50000, credit_amount: 0 },
])
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.get('1930')).toEqual({ debit: 100000, credit: 15000 })
expect(balances.has('3001')).toBe(false)
expect(balances.has('5410')).toBe(false)
})
it('aggregates multiple lines per account', async () => {
mockFetchAllRows.mockResolvedValue([
{ account_number: '1510', debit_amount: 5000, credit_amount: 0 },
+7
View File
@@ -77,6 +77,13 @@ export async function getOpeningBalances(
)
for (const line of priorLines) {
// P&L accounts (class 3-8) reset to zero at each year transition —
// their balances are absorbed into årets resultat (2099) and rolled
// into equity. Carrying them forward as IB causes resultatkonton to
// accumulate across years (BFNAR 2013:2 violation).
const cls = parseInt(line.account_number.charAt(0), 10)
if (cls >= 3 && cls <= 8) continue
const existing = balances.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
@@ -0,0 +1,137 @@
-- Processing history (behandlingshistorik)
-- Append-only event log recording every legally significant act per BFNAR 2013:2 kap 8.
-- 7-year retention enforced at application layer (not DB trigger) to allow GDPR redaction.
-- Immutable: UPDATE blocked via audit_log_immutable() trigger.
--
-- seq ordering: BIGSERIAL assigns at statement time, not commit time.
-- Two concurrent transactions may produce seq values out of commit order.
-- For legally meaningful ordering, use occurred_at. seq is a cursor for polling.
-- =============================================================
-- 1. Event type reference table
-- =============================================================
-- Adding a new event type = INSERT, no ALTER TABLE required.
CREATE TABLE public.processing_event_types (
event_type TEXT PRIMARY KEY
);
ALTER TABLE public.processing_event_types ENABLE ROW LEVEL SECURITY;
-- Readable by all authenticated users (reference data)
CREATE POLICY "processing_event_types_select" ON public.processing_event_types
FOR SELECT USING (true);
-- Seed the v0.2 event catalog (28 types)
INSERT INTO public.processing_event_types (event_type) VALUES
-- Document stream
('DocumentIngested'),
('DocumentExtractionAttempted'),
('DocumentClassified'),
('DocumentRejected'),
('DocumentArchived'),
('DocumentSupersededByDuplicate'),
-- BankTransaction stream
('BankTransactionIngested'),
('BankTransactionEnriched'),
-- MatchProposal stream
('MatchAttemptedDeterministic'),
('MatchAttemptedLlm'),
('MatchConfirmed'),
('MatchRejected'),
-- Verifikation stream
('ForslagCreated'),
('ForslagMutated'),
('VerifikationCommitted'),
('RättelseverifikationIssued'),
('ForslagAbandoned'),
-- CounterpartyTemplate stream
('CounterpartyObserved'),
('TemplateStrengthened'),
('TemplateBroken'),
-- Period stream
('PeriodCloseInitiated'),
('PeriodClosed'),
('PeriodReopened'),
-- Migration stream
('MigrationInitiated'),
('MigrationValidated'),
('MigrationCommitted'),
-- System stream
('TimingCeilingTriggered'),
('RubricVersionPublished');
-- =============================================================
-- 2. Processing history table
-- =============================================================
CREATE TABLE public.processing_history (
event_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
seq BIGSERIAL UNIQUE NOT NULL,
company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT,
correlation_id UUID NOT NULL,
causation_id UUID REFERENCES public.processing_history(event_id),
aggregate_type TEXT NOT NULL CHECK (aggregate_type IN (
'Document',
'BankTransaction',
'MatchProposal',
'Verifikation',
'CounterpartyTemplate',
'Period',
'Migration',
'System'
)),
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL REFERENCES public.processing_event_types(event_type),
payload JSONB NOT NULL DEFAULT '{}',
payload_schema_version SMALLINT NOT NULL DEFAULT 1,
actor JSONB NOT NULL,
rubric_version TEXT,
occurred_at TIMESTAMPTZ NOT NULL,
appended_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- No updated_at: append-only table
);
-- =============================================================
-- 3. Row-level security
-- =============================================================
ALTER TABLE public.processing_history ENABLE ROW LEVEL SECURITY;
-- SELECT: users can read their companies' processing history
CREATE POLICY "processing_history_select" ON public.processing_history
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
-- No INSERT/UPDATE/DELETE policies for authenticated users.
-- Writes via service role client only.
-- =============================================================
-- 4. Indexes
-- =============================================================
-- Aggregate history: "full history of this document/transaction/verifikation"
CREATE INDEX idx_ph_aggregate
ON public.processing_history (company_id, aggregate_type, aggregate_id, seq);
-- Event type filtering: "all VerifikationCommitted events for this company"
CREATE INDEX idx_ph_company_event_type
ON public.processing_history (company_id, event_type, seq);
-- Time range: "all events for this company in March 2026" + retention cleanup
CREATE INDEX idx_ph_company_occurred
ON public.processing_history (company_id, occurred_at);
-- =============================================================
-- 5. Immutability trigger
-- =============================================================
-- Reuses audit_log_immutable() which unconditionally blocks all UPDATE operations.
CREATE TRIGGER processing_history_no_update
BEFORE UPDATE ON public.processing_history
FOR EACH ROW EXECUTE FUNCTION public.audit_log_immutable();
-- =============================================================
-- 6. Schema reload
-- =============================================================
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,12 @@
-- Add the missing BEFORE DELETE immutability trigger on processing_history.
--
-- Mirrors the pattern from migration 014 (audit_log) where both UPDATE and
-- DELETE are blocked via audit_log_immutable(). Without this trigger the
-- service role could silently remove rows, contradicting BFNAR 2013:2 kap 8
-- behandlingshistorik immutability requirements.
CREATE TRIGGER processing_history_no_delete
BEFORE DELETE ON public.processing_history
FOR EACH ROW EXECUTE FUNCTION public.audit_log_immutable();
NOTIFY pgrst, 'reload schema';
+37
View File
@@ -111,6 +111,43 @@ export type MomsPeriod = 'monthly' | 'quarterly' | 'yearly'
// Reconciliation method
export type ReconciliationMethod = 'auto_exact' | 'auto_date_range' | 'auto_reference' | 'auto_fuzzy' | 'manual'
// Processing history (behandlingshistorik) — event-driven audit trail per BFNAR 2013:2 kap 8
export type ProcessingHistoryActorType = 'user' | 'system' | 'llm' | 'cron' | 'api_key'
export interface ProcessingHistoryActor {
type: ProcessingHistoryActorType
id: string
label?: string
}
export type ProcessingHistoryAggregateType =
| 'Document'
| 'BankTransaction'
| 'MatchProposal'
| 'Verifikation'
| 'CounterpartyTemplate'
| 'Period'
| 'Migration'
| 'System'
export interface ProcessingHistoryEvent {
event_id: string
seq: number
company_id: string
correlation_id: string
causation_id: string | null
aggregate_type: ProcessingHistoryAggregateType
aggregate_id: string
event_type: string // open type — validated at runtime against processing_event_types registry
payload: Record<string, unknown>
payload_schema_version: number
actor: ProcessingHistoryActor
rubric_version: string | null
occurred_at: string
appended_at: string
}
// Bank connection status
export type BankConnectionStatus = 'pending' | 'active' | 'expired' | 'revoked' | 'error'