fix: Swedish VAT/SIE compliance, storno hardening, document integrity (#209)

* feat: add INK2 declaration improvements, invoice delivery date, and Swedish compliance skills

Expand INK2 engine with full INK2S/INK2R support and improved SRU generation.
Add delivery_date field to invoices and corresponding PDF/migration support.
Add Claude skills for Swedish asset accounting, invoice compliance, SIE import/export, SRU filing, and tax planning.

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

* fix: address PR review — map BAS 4500–4899, strip CRLF in SRU, document P3

- Map BAS accounts 4500–4599 (legoarbeten), 4700–4899 (diverse
  varuinköpskostnader) to SRU 7512 so they are not silently dropped
  from INK2R declarations
- Strip \r\n in sanitizeString to prevent CRLF injection in SRU fields
- Document P3 period suffix limitation for brutet räkenskapsår

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

* fix: correct BAS 4500-4599, 4700-4899 mapping from 7512 to 7511

Per the official BAS-to-SRU mapping, these account ranges are cost of
goods (legoarbeten, inkurans, svinn) and belong under 7511 (Råvaror
och förnödenheter), not 7512 (Handelsvaror). 7512 remains 4600-4699.

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

* fix: Swedish VAT compliance — representation VAT, domestic RC, full BAS 26xx mapping, SIE encoding

- Representation expenses now default to reduced_12 VAT (ML 13 kap 24-25 §§);
  income tax deduction was abolished 2017 but VAT deduction at 12% remains
- Domestic reverse charge (byggtjänster etc.) uses 2647 instead of 2645,
  with distinct line descriptions for Swedish vs EU/non-EU RC
- VAT declaration maps all BAS 26xx variant accounts (egna uttag 2612/2622/2632,
  uthyrning 2613/2623/2633, VMB 2616/2626/2636, import 2615/2625/2635,
  domestic RC 2647, frivillig skattskyldighet 2642) and revenue variants
  (3108/3105/3004/3100) to correct momsdeklaration rutor
- SIE parser: remove unreliable #FORMAT PC8 encoding detection (most software
  exports UTF-8 with PC8 header), parse #FLAGGA for import-already-done warning,
  default SIE type to 1 when absent, fix RTRANS/BTRANS documentation
- SIE export: add #RAR -1 (previous fiscal year), fix UB = IB + movements
- Error messages: add pattern matching for locked period trigger errors

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

* fix: address Greptile review — update ruta49 JSDoc, use null sentinel in error map

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

* fix: harden storno CAS guard, document integrity, and BFNAR archive compliance

- Storno: defer original→reversed until both entries succeed, add CAS guard
  for concurrent reversals, use cancelEntry() instead of delete
- Document: add document.accessed event, enrich archive manifest with metadata,
  add BFNAR 2013:2 systemdokumentation to full archive export
- Verify cron: run daily, configurable batch size, include company_id in audit
- Migrations: integrity audit actions, document version chain, metadata
  immutability, audit deletions, fix immutability for posted/cancelled

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

* fix: address Greptile review — allow is_current_version in immutability trigger, log cancelEntry errors

- Remove is_current_version from blocked fields in enforce_document_metadata_immutability
  trigger so create_document_version RPC can supersede documents linked to posted entries
- Add error logging to cancelEntry for observability on cleanup failures

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-09 16:12:03 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6ccd4f429c
commit b484e9a7b4
17 changed files with 1214 additions and 47 deletions
@@ -0,0 +1,55 @@
---
name: swedish-financial-reporting
description: >
Swedish financial reporting: årsredovisning structure (K2/K3 uppställningsform, noter, förvaltningsberättelse, underskrifter), Bolagsverket filing (deadlines, förseningsavgifter, iXBRL, revisionsplikt, tvångslikvidation), INK2 form logic (INK2R/INK2S field mappings, skattemässiga justeringar, periodiseringsfond/överavskrivningar/koncernbidrag). Trigger on ANY årsredovisning structure question, K2/K3 reporting differences, noter requirements, förvaltningsberättelse, underskrifter, kassaflödesanalys, Bolagsverket filing, förseningsavgifter, digital inlämning, iXBRL, revisionsplikt, INK2/INK2R/INK2S, skattemässiga justeringar, BAS-to-INK2 mappings, N9. Also "vilka noter krävs", "deadline årsredovisning", "hur fyller jag i INK2", "vem ska skriva under". Not for bokslut/closing mechanics (use swedish-year-end-closing). Always use over training data.
---
# Swedish Financial Reporting
Authoritative reference for årsredovisning document structure, Bolagsverket filing, and INK2 form logic. This skill covers the **reporting and filing** layer; for closing entries and bokslutstransaktioner, use `swedish-year-end-closing`. For VAT declarations, use `swedish-vat`. For tax planning, use `swedish-tax-planning`.
## How to use this skill
This skill has three reference files. Read the one(s) relevant to the query:
| Query about | Read |
|---|---|
| Årsredovisning structure, K2/K3 document format, noter, förvaltningsberättelse, underskrifter, kassaflödesanalys, flerårsöversikt | `references/arsredovisning-structure.md` |
| Bolagsverket filing, deadlines, förseningsavgifter, digital filing, iXBRL, revisionsplikt, tvångslikvidation | `references/bolagsverket-filing.md` |
| INK2 form, INK2R/INK2S fields, skattemässiga justeringar, BAS-to-INK2 mapping, periodiseringsfond/överavskrivningar/koncernbidrag in declaration, common errors | `references/ink2-form-logic.md` |
For questions spanning multiple areas (e.g. "what's the full timeline from bokslut to filing?"), read all relevant files.
## Quick-reference: critical thresholds
### Större företag (ÅRL 1:3 § p.4) -- 2 of 3 over 2 years
- Anställda > 50
- Balansomslutning > 40 MSEK
- Nettoomsättning > 80 MSEK
### Revisionsplikt exemption (ABL 9:1 §) -- must NOT exceed 2 of 3
- Anställda > 3
- Balansomslutning > 1.5 MSEK
- Nettoomsättning > 3 MSEK
### Key deadlines (calendar year AB, FY ending Dec 31)
- Årsredovisning to Bolagsverket: **7 months** (July 31)
- INK2 to Skatteverket: **~7 months** (August 1, or next weekday)
- Förseningsavgift 1: day 1 after deadline
- Förseningsavgift 2: +2 months
- Förseningsavgift 3 + likvidationshotbrev: +2 more months (11 months total)
### Förseningsavgifter (FY beginning 2025-01-01+)
- Privat AB: 7,500 + 7,500 + 15,000 = **30,000 SEK**
- Publikt AB: 15,000 + 15,000 + 30,000 = **60,000 SEK**
## Key law references
| Statute | Area |
|---|---|
| ÅRL (1995:1554) | Document structure, notes, filing |
| ABL (2005:551) | Revision, signatures, liquidation |
| IL (1999:1229) | All tax adjustments (INK2) |
| SFL (2011:1244) | Declaration duties, deadlines, penalties |
| BFNAR 2016:10 | K2 |
| BFNAR 2012:1 | K3 |
@@ -0,0 +1,207 @@
# Årsredovisning Document Structure (K2/K3)
## Table of Contents
1. Document ordering
2. Uppställningsform -- resultaträkning
3. Uppställningsform -- balansräkning
4. Noter requirements (K2 vs K3)
5. Förvaltningsberättelse
6. Underskrifter
7. Kassaflödesanalys
8. Flerårsöversikt
9. K2 vs K3 key differences
---
## 1. Document ordering
**K2 (BFNAR 2016:10 punkt 3.2)**:
Förvaltningsberättelse → Resultaträkning → Balansräkning → (Kassaflödesanalys) → Noter
**K3 (BFNAR 2012:1 punkt 3.4, 3.10)**:
Same base order, plus optional Rapport över förändringar i eget kapital as separate statement between balansräkning and kassaflödesanalys.
---
## 2. Uppställningsform -- resultaträkning
Governed by ÅRL 3 kap. 1-4 §§, templates in ÅRL Bilaga 2 (kostnadsslagsindelad) and Bilaga 3 (funktionsindelad).
### K2: Only kostnadsslagsindelad (punkt 3.4)
Funktionsindelad is explicitly prohibited. Fixed rubriker/poster/summeringsrader. No additional rows or headings may be added, no merging beyond what K2 permits.
Förkortad (abbreviated) form allowed for mindre företag per ÅRL 3:4 § and 3:11 §. If used, nettoomsättning must be disclosed in a note (punkt 18.8A).
### K3: Both formats allowed
Both kostnadsslagsindelad and funktionsindelad permitted. Additional rows may be added or merged per ÅRL 3:4 §. Switching formats requires särskilda skäl (ÅRL 3:3 §).
### Kostnadsslagsindelad line items (ÅRL Bilaga 2)
Nettoomsättning → Förändring av varulager → Aktiverat arbete för egen räkning → Övriga rörelseintäkter → Råvaror och förnödenheter → Handelsvaror → Övriga externa kostnader → Personalkostnader → Avskrivningar/nedskrivningar → Övriga rörelsekostnader → **Rörelseresultat** → Finansiella poster → **Resultat efter finansiella poster** → Bokslutsdispositioner → Skatt på årets resultat → **Årets resultat**
### Funktionsindelad line items (ÅRL Bilaga 3, K3 only)
Nettoomsättning → Kostnad för sålda varor → **Bruttoresultat** → Försäljningskostnader → Administrationskostnader → Övriga rörelseintäkter/kostnader → Rörelseresultat → then same financial items as above.
---
## 3. Uppställningsform -- balansräkning
Both K2 and K3 follow ÅRL Bilaga 1.
Under K2: strict adherence to prescribed posts (punkt 4.7-4.8), no extra posts. Eget kapital split into bundet/fritt per ÅRL 3:10a-10b §§.
Under K3: additional posts may be added per ÅRL 3:4 §.
---
## 4. Noter requirements
Notes governed by ÅRL 5 kap. §§ 4-24 (all companies) and §§ 25-49 (större företag only). Notes must appear in same order as related balance/income statement posts (ÅRL 5:3 §).
### K2 mandatory notes (BFNAR 2016:10 kap. 18-19)
- **Redovisnings- och värderingsprinciper** (ÅRL 5:4 §, punkt 18.3-18.4): statement that K2 is applied plus depreciation grounds
- **Avskrivningsgrunder** per asset category (ÅRL 5:8 § andra stycket)
- **Goodwill avskrivningstid** if goodwill recognized (ÅRL 5:9 §)
- **Tjänste-/entreprenaduppdrag** method (punkt 18.5)
- **Indirekta utgifter i pågående arbeten** if indirect costs capitalized (punkt 18.6)
- **Indirekta tillverkningskostnader** if included in inventory (punkt 18.7)
- **Nettoomsättning** if förkortad resultaträkning used (ÅRL 3:11 §, punkt 18.8A)
- **Medelantalet anställda** (ÅRL 5:20 §, punkt 18.9)
- **Anläggningstillgångar** -- anskaffningsvärde, årets avskrivningar per post (ÅRL 5:8 §)
- **Uppskrivningsfond** changes during year (ÅRL 5:11 §)
- **Långfristiga skulder** due after >5 years (ÅRL 5:13 §)
- **Ställda säkerheter** (ÅRL 5:14 §)
- **Eventualförpliktelser** (ÅRL 5:15 §)
- **Exceptionella intäkter/kostnader** if material (ÅRL 5:19 §)
- **Koncernförhållanden** (kap. 19): ränteintäkter/kostnader avseende koncernföretag, moderföretag (namn, org.nr, säte)
- **Väsentliga händelser efter räkenskapsårets slut** (ÅRL 5:22 §)
- **Lån till ledande befattningshavare** if applicable (ÅRL 5:12 §)
- **Övergångsupplysning** first year applying K2 (punkt 18.1)
**NOT required under K2**: jämförelsetal in notes (though permitted), specifikation of shares in dotterföretag/intresseföretag, uppskjuten skatt (prohibited in K2), könsfördelning, salary details.
### K3 mandatory notes (BFNAR 2012:1 kap. 8 + ÅRL)
All K2 base notes from ÅRL 5:4-24 §§, plus:
- Detailed **redovisningsprinciper** (punkt 8.3): all policies relevant for understanding (intäktsredovisning, leasing, materiella/immateriella anläggningstillgångar, financial instruments, inventory, uppskjuten skatt, etc.)
- **Uppskjuten skatt** mandatory recognition (K3 kap. 29); större företag must disclose amounts and changes (ÅRL 5:36 §)
- **Ändrade uppskattningar och bedömningar** if material (punkt 10.6, 10.12)
- **Rättelse av fel** (punkt 10.12)
### Additional notes for större företag (both K2 and K3, ÅRL 5:25-49 §§)
Könsfördelning (5:37-38 §§), löner/ersättningar/pensioner to board/CEO (5:39-44 §§), antal aktier/kvotvärde (5:34 §), koncerntransaktioner (5:26 §), dotterföretag/intresseföretag specifikation (5:27-30 §§), jämförelsetal required in all notes under K3 (punkt 3.5).
---
## 5. Förvaltningsberättelse
Governed by ÅRL 6 kap. 1-4 §§.
### All companies must include:
- Rättvisande översikt over verksamhet, ställning, resultat
- Förhållanden important for assessing position/results not reported elsewhere
- Väsentliga händelser during räkenskapsår
### Större företag must additionally cover:
- Förväntad framtida utveckling (including väsentliga risker och osäkerhetsfaktorer)
- Forskning och utveckling
- Utländska filialer
- Miljöinformation for tillstånds-/anmälningspliktig verksamhet
### K2 mandatory rubriker for AB (punkt 4.9):
- Verksamheten (art och inriktning, säte)
- Väsentliga händelser under räkenskapsåret
- Väsentliga händelser efter räkenskapsårets slut (note: in K2 this goes in förvaltningsberättelsen, not as a separate note)
- Flerårsöversikt
- Förändring av eget kapital (always in förvaltningsberättelsen under K2)
- Förslag till resultatdisposition (per ABL 18:4 §, ÅRL 6:2 §)
Also required: disclosure if kontrollbalansräkning prepared/should be (ABL 25:13 §), name changes, important external dependencies.
### K3 additions (punkt 3.7-3.14):
- Ägare with >10% of shares/votes must be disclosed
- More detailed framtida utveckling och risker
- Forsknings- och utvecklingspolicy (punkt 3.9)
- Kontrollbalansräkning disclosure (punkt 3.11)
- Miljöinformation for larger companies (punkt 3.12)
- Option to place förändring av eget kapital as separate statement (punkt 3.10)
### Resultatdisposition
ÅRL 6:2 § requires AB and ekonomisk förening to include förslag till resultatdisposition in förvaltningsberättelsen.
---
## 6. Underskrifter
### Who signs (ÅRL 2:7 §)
ALL ordinarie styrelseledamöter registered at Bolagsverket at signing time PLUS VD (if appointed).
### Order
1. VD and all styrelseledamöter sign årsredovisning
2. Revisor signs revisionsberättelse and påteckningsmening
### Digital signatures
ÅRL 2:7 § allows elektronisk form with avancerad elektronisk underskrift per EU eIDAS-förordningen (EU nr 910/2014). BankID is standard in Sweden. Physical and digital signatures CANNOT be mixed on the same document.
### Dating requirement (FY starting 2024-07-01+)
Årsredovisning must be dated the day signatories determined final content. Each signer must indicate their signature date.
### Legal consequences
Signatories take personal responsibility for ÅRL compliance. False/misleading årsredovisning can trigger liability under BrB (svindleri, bokföringsbrott).
---
## 7. Kassaflödesanalys
### When mandatory
- Större företag: always (ÅRL 2:1 § andra stycket)
- Koncernredovisning: always (ÅRL 7:4 §)
- Bostadsrättsföreningar: FY starting after 2022-12-31 (SFS 2022:1028)
### K2 (kap. 21): voluntary
If prepared, only indirekt metod permitted (punkt 21.9). Placed between balansräkning and noter (punkt 21.2).
### K3 (kap. 7): mandatory for större, voluntary for mindre
Both direkt and indirekt metod permitted (punkt 7.9-7.10).
### Required categories (ÅRL 6:5 §)
Löpande verksamhet, investeringsverksamhet, finansieringsverksamhet.
---
## 8. Flerårsöversikt
### K2 (punkt 5.5, 4.9)
Mandatory in förvaltningsberättelsen. Minimum 4 years (current + 3 preceding). Three obligatoriska nyckeltal:
- Nettoomsättning (or bruttoresultat if förkortad)
- Resultat efter finansiella poster
- Soliditet (justerat eget kapital / balansomslutning x 100%)
If nettoomsättning varies >30% between any two years, must be commented.
### K3 (punkt 3.7)
Rättvisande översikt over longer period, normally 4-5 years. No specific mandatory nyckeltal prescribed; company decides which KPIs best describe development.
---
## 9. K2 vs K3 key differences in reporting
| Area | K2 | K3 |
|---|---|---|
| Resultaträkning format | Only kostnadsslagsindelad | Both formats |
| Egenupparbetade immateriella | Forbidden to capitalize | Allowed |
| Komponentavskrivning | Forbidden | Required when components differ |
| Uppskjuten skatt | Forbidden | Required |
| Verkligt värde | Not allowed | Allowed per ÅRL |
| Nyttjandeperiod inventarier | May use 5-year schablon | Must assess actual |
| Periodiseringsregler | Simplified (<20% variation, <5,000 kr) | Case-by-case |
| Uppskrivningar | Only byggnader & mark (to taxeringsvärde) | All fixed assets per ÅRL |
| Leasing | All operating | Financial recognized on BS (koncern) |
### Who can use K2
Mindre företag (not exceeding 50 employees / 40 MSEK balansomslutning / 80 MSEK nettoomsättning), excluding publika AB and moderföretag preparing koncernredovisning.
**New from FY starting after 2025-12-31**: bostadsrättsföreningar, companies with kryptotillgångar, filialer utomlands, incitamentsprogram, or emitterade konvertibla skuldebrev may NOT use K2.
### Who must use K3
All större företag not applying IFRS. K3 is huvudregelverk (default) for all companies preparing årsredovisning.
@@ -0,0 +1,152 @@
# Bolagsverket Filing Requirements
## Table of Contents
1. What must be filed
2. Filing deadlines
3. Förseningsavgifter
4. Escalation and tvångslikvidation
5. Digital filing and iXBRL
6. Revisionsplikt gränsvärden
7. Filing by entity type
---
## 1. What must be filed
### Aktiebolag
- Årsredovisning (förvaltningsberättelse, resultaträkning, balansräkning, noter, kassaflödesanalys if required)
- Revisionsberättelse (if revisor exists)
- **Fastställelseintyg**: certification that RR and BR were adopted at årsstämma, with date and resolution on resultatdisposition
- Koncernredovisning and koncernrevisionsberättelse if obligated parent company
### Ekonomisk förening (FY beginning 2025-01-01+)
ALL ekonomiska föreningar must file årsredovisning and revisionsberättelse with Bolagsverket. Previously only larger ones were required to.
### Handelsbolag with juridisk person as partner
Årsredovisning filed within **6 months** (not 7) from FY end.
### Stiftelser
Filed with **Länsstyrelsen** (not Bolagsverket) within **6 months**.
---
## 2. Filing deadlines
Core rule (ÅRL 8:3 §): årsredovisning must reach Bolagsverket **within 7 months** from räkenskapsårets slut. Bolagsverket has **no legal authority to grant extensions**.
### Calendar year AB (Dec 31 FY end)
Deadline: **July 31** of following year.
### Brutet räkenskapsår
7-month rule applies from whatever date FY ends:
- FY ending Jun 30 → deadline Jan 31
- FY ending Aug 31 → deadline Mar 31
### Internal timeline
- Prepare årsredovisning within ~4.5 months
- If revisor: ready at least 6 weeks before AGM for audit
- AGM within 6 months from FY end
- File within 1 month after AGM
- Total max 7 months
### Continued AGM (fortsatt bolagsstämma)
If properly reported, filing deadline extends to **9 months**.
---
## 3. Förseningsavgifter
Legal reference: ÅRL 8:5-6 §§. Three fees at escalating intervals.
### FY beginning 2025-01-01 or later (current regime)
| | Privat AB & ekon. förening | Publikt AB |
|---|---|---|
| Avgift 1 (day 1 late) | 7,500 SEK | 15,000 SEK |
| Avgift 2 (+2 months) | 7,500 SEK | 15,000 SEK |
| Avgift 3 (+2 more months) | 15,000 SEK | 30,000 SEK |
| **Total max** | **30,000 SEK** | **60,000 SEK** |
### Legacy amounts (FY beginning before 2025-01-01)
- Privat AB: 5,000 + 5,000 + 10,000 = 20,000 SEK
- Publikt AB: 10,000 + 10,000 + 20,000 = 40,000 SEK
Förseningsavgifter are **ej skattemässigt avdragsgilla**. Unpaid amounts sent to Kronofogden.
---
## 4. Escalation and tvångslikvidation
Timeline from FY end:
| Time | Event |
|---|---|
| 7 months + 1 day | Förseningsavgift 1 |
| 9 months | Förseningsavgift 2 |
| 11 months | Förseningsavgift 3 AND Bolagsverket can issue **föreläggande om likvidation** (ABL 25:11 §) |
| After föreläggande | Company must rectify within set deadline or Bolagsverket decides on **tvångslikvidation** and appoints likvidator |
| 15 months | Board members and VD risk **personligt betalningsansvar** for company debts |
Additionally:
- Failure to prepare årsredovisning within 6 months can constitute **bokföringsbrott** (BrB) regardless of filing
- Bolagsverket can issue **vitesförelägganden** against individual board members and VD (ÅRL 8:13 §)
---
## 5. Digital filing and iXBRL
### Availability
Digital filing available since 2018 (K2 AB) and 2019 (K3 AB). Remains **voluntary**; paper filing still accepted (post to Bolagsverket Årsredovisningar, SE-851 98 Sundsvall).
Currently supported only for **aktiebolag using K2 or K3**. Not yet available for: handelsbolag, ekonomiska föreningar, stiftelser.
### iXBRL technical requirements
- Format: **Inline XBRL (iXBRL) version 1.1**
- Files must be valid XHTML with embedded XBRL tags
- Extension: **.xhtml**
- Encoding: **UTF-8**
- Max size: **5 MB**
- Images: JPEG, SVG, GIF, PNG only
- No scripts allowed
### Taxonomy
Developed by BFN, Skatteverket, Finansinspektionen, SCB, Bolagsverket, FAR. Published at **taxonomier.se**.
- K2 taxonomy: BFNAR 2016:10 K2 ÅR (published 2017-09-30)
- K3 taxonomy: BFNAR 2012:1 K3 (updated 2021-10-31)
### Filing process
1. Software generates iXBRL file and uploads to Bolagsverket's "Eget utrymme"
2. Styrelseledamot or VD logs in with **BankID** to digitally sign fastställelseintyg
3. Årsredovisning not considered filed until fastställelseintyg is signed
4. Bolagsverket validates company name, signatory registration, etc. automatically
---
## 6. Revisionsplikt gränsvärden
Per ABL 9:1 §, a private AB may opt out of audit if it does NOT exceed **2 of 3** thresholds during **each of the two most recent FY**:
| Criterion | Threshold |
|---|---|
| Medelantal anställda | > 3 |
| Balansomslutning | > 1,500,000 SEK |
| Nettoomsättning | > 3,000,000 SEK |
Opt-out must be registered in bolagsordning. For parent companies, assessed at GROUP level.
### Always-audited entities
Publika AB, börsnoterade, banks, insurance companies, SVB-bolag, advokataktiebolag, municipally owned AB.
Thresholds unchanged since 2010. SOU 2021:60 recommended raising them but no legislative change as of April 2026.
---
## 7. Filing by entity type
| Entity type | Must file ÅR? | Filed with | Deadline | Revisionsberättelse? | Digital? |
|---|---|---|---|---|---|
| Privat AB | Always | Bolagsverket | 7 months | If revisor exists | Yes (K2/K3) |
| Publikt AB | Always | Bolagsverket | 7 months | Always | Yes (K2/K3) |
| Ekon. förening (FY 2025+) | All | Bolagsverket | 7 months | Yes (always has revisor) | Not yet |
| HB (jur. person partner) | Yes | Bolagsverket | **6 months** | If revisionsplikt | Not yet |
| Stiftelse | Yes | **Länsstyrelsen** | **6 months** | Most have revisionsplikt | Not yet |
@@ -0,0 +1,285 @@
# INK2 Form Logic
## Table of Contents
1. INK2 form structure
2. Data flow
3. INK2R field mappings (BAS → SRU → INK2)
4. INK2S skattemässiga justeringar
5. Periodiseringsfond in INK2
6. Överavskrivningar in INK2
7. Koncernbidrag in INK2
8. Ränteavdragsbegränsningar (N9)
9. Common errors and traps
10. Filing deadlines
---
## 1. INK2 form structure
INK2 (Inkomstdeklaration 2, SKV 2002) has three core parts plus optional bilagor:
### INK2 -- Huvudblanketten (main form)
Summary of taxable income. Fields 1.1-1.15.
- **1.1** Överskott of näringsverksamhet (from INK2S 4.15)
- **1.2** Underskott of näringsverksamhet (from INK2S 4.16)
- **1.4** Särskild löneskatt på pensionskostnader (rate **24.26%**)
- **1.6a** Avkastningsskatt (rate **15%**)
- **1.7-1.15** Fastighetsskatt/avgift
### INK2R -- Räkenskapsschema
Balansräkning (fields 2.1-2.50) and resultaträkning (fields 3.1-3.27). Mirrors ÅRL structure. Auto-populated from BAS accounts via SRU coupling tables when importing SIE files.
### INK2S -- Skattemässiga justeringar
Fields 4.1-4.22. Transforms bokfört resultat into skattemässigt resultat. These fields are **NOT auto-coupled** to BAS accounts; they require manual entry or program-specific logic.
### Bilagor
- **N3B** -- andel i handelsbolag
- **N4** -- andelsbyte/uppskov
- **N7** -- nedsättning avkastningsskatt
- **N8** (SKV 2155) -- skogsavdrag/substansminskningsavdrag
- **N9** (SKV 2158) -- begränsning av ränteavdrag (required if deducting under EBITDA rule or transferring/receiving net interest within a group)
---
## 2. Data flow
```
Bokföring (BAS accounts)
↓ SRU coupling
INK2R (fields 2.x, 3.x) -- accounting figures
↓ 3.26/3.27 (Årets resultat)
INK2S (fields 4.x) -- tax adjustments
↓ 4.15/4.16 (Överskott/Underskott)
INK2 (fields 1.x) -- final taxable result
```
Specifically: INK2R 3.26/3.27 → INK2S 4.1/4.2 → adjustments → INK2S 4.15/4.16 → INK2 1.1/1.2.
---
## 3. INK2R field mappings
### Resultaträkning (key fields)
| INK2R | Description | BAS accounts | Fältkod |
|---|---|---|---|
| 3.1 | Nettoomsättning | 30xx-37xx | 7410 |
| 3.5 | Råvaror och förnödenheter | 40xx-47xx, 4910-4929 | 7511 |
| 3.7 | Övriga externa kostnader | 50xx-69xx | 7513 |
| 3.8 | Personalkostnader | 70xx-76xx | 7514 |
| 3.9 | Av-/nedskrivningar materiella & immateriella | 77xx-78xx (exkl. 774x, 779x) | 7515 |
| 3.16 | Övriga ränteintäkter | 83xx (exkl. 837x, 838x) | 7417 |
| 3.18 | Räntekostnader | 84xx | 7522 |
| 3.19 | Lämnade koncernbidrag | 883x | 7524 |
| 3.20 | Mottagna koncernbidrag | 882x | 7419 |
| 3.21 | Återföring periodiseringsfond | 8810, 8819 | 7420 |
| 3.22 | Avsättning periodiseringsfond | 8810, 8811 | 7525 |
| 3.23 | Förändring av överavskrivningar | 885x | 7421(+)/7526(-) |
| 3.25 | Skatt på årets resultat | 89xx (exkl. 899x) | 7450 |
| 3.26 | Årets resultat, vinst → p. 4.1 | +899x | 7550 |
### Balansräkning (key fields)
| INK2R | Description | BAS accounts | Fältkod |
|---|---|---|---|
| 2.1 | Immateriella anläggningstillgångar | 10xx | 7201 |
| 2.3 | Byggnader och mark | 11xx | 7214 |
| 2.4 | Maskiner/inventarier | 12xx | 7215 |
| 2.7 | Andelar i koncernföretag | 131x | 7230 |
| 2.26 | Kassa/bank | 19xx | 7281 |
| 2.29 | Periodiseringsfonder | 211x-213x | 7321 |
| 2.30 | Ackumulerade överavskrivningar | 215x | 7322 |
---
## 4. INK2S skattemässiga justeringar
### The adjustment formula
```
Bokfört resultat (4.1 vinst / 4.2 förlust)
+ 4.3 Ej avdragsgilla kostnader
- 4.4 Avdragsgilla ej bokförda kostnader
- 4.5 Ej skattepliktiga intäkter
+ 4.6 Skattepliktiga ej bokförda intäkter
± 4.7 Avyttring av delägarrätter
± 4.8 Andel i handelsbolag
± 4.9 Skattemässig justering avskrivning byggnader/inventarier
± 4.10 Fastighetsförsäljning
± 4.11 Skogsavdrag
± 4.12 Återföring värdeminskningsavdrag
± 4.13 Övriga justeringar
- 4.14 Underskott från tidigare år
= 4.15 Överskott OR 4.16 Underskott
```
### Punkt 4.3 -- Ej avdragsgilla kostnader (additions)
- **4.3a** = skatt på årets resultat (auto from p. 3.25, BAS 7651)
- **4.3b** = nedskrivning av finansiella tillgångar (from p. 3.17, BAS 7652). **HIGH ERROR RATE** -- almost never tax-deductible but frequently missed.
- **4.3c** = andra ej avdragsgilla (böter, skattetillägg, förseningsavgifter, gåvor, ej avdragsgill representation, BAS 7653)
### Punkt 4.4 -- Avdragsgilla ej bokförda kostnader (deductions)
- **4.4a** = lämnade koncernbidrag not booked through P&L (e.g. via equity under K3/RFR 2, BAS 7751)
- **4.4b** = andra ej bokförda avdragsgilla kostnader (BAS 7764)
### Punkt 4.5 -- Ej skattepliktiga intäkter (deductions)
- **4.5a** = skattefria ackordsvinster (BAS 7752)
- **4.5b** = skattefri utdelning on näringsbetingade andelar per IL 24 kap. (BAS 7753)
- **4.5c** = andra skattefria intäkter (BAS 7754)
### Punkt 4.6 -- Skattepliktiga ej bokförda intäkter (additions)
- **4.6a** = schablonintäkt på periodiseringsfonder (statslåneräntan x summa fonder, floor 0.5%, BAS 7654). **COMMONLY FORGOTTEN** -- pure tax item, not booked.
- **4.6b** = schablonintäkt på fondandelar (0.4% of ingående värde, BAS 7668)
- **4.6c** = mottagna koncernbidrag ej bokförda via resultat (BAS 7655)
- **4.6d** = uppräknat belopp vid återföring av periodiseringsfond (106% pre-2019, 104% 2019-2020, 100% from 2021, BAS 7667)
### Punkt 4.7 -- Avyttring delägarrätter
- **4.7a** = bokförd vinst (deducted, BAS 7755)
- **4.7b** = bokförd förlust (added, BAS 7656)
- **4.7e** = skattemässig kapitalvinst (BAS 7658)
- **4.7f** = skattemässig kapitalförlust -- the **aktiefållan** (only offsetable against future aktievinster, BAS 7757)
### Punkt 4.14 -- Underskott från tidigare år
- **4.14a** = outnyttjat underskott from prior year (BAS 7763)
- **4.14b** = reduktion due to beloppsspärr, ackord, konkurs (BAS 7664)
- **4.14c** = koncernbidragsspärrat/fusionsspärrat underskott (BAS 7670)
### Tilläggsupplysningar (4.17-4.22)
Värdeminskningsavdrag on byggnader/markanläggningar remaining at year-end, restvärdesavskrivning inventarier, skulder till närstående, pensionskostnader, koncernbidrags-/fusionsspärrat underskott.
---
## 5. Periodiseringsfond in INK2
Legal reference: IL 30 kap.
- Max avsättning: **25%** of skattemässigt överskott (30:5 §)
- Must be reversed by **6th tax year** after avsättning (30:7 §)
- Requires corresponding booking in räkenskaperna (obeskattade reserver)
- Annual schablonintäkt: statslåneräntan (30 Nov) x total fonder at year start (30:6a §), floor **0.5%**
- SLR 30 Nov 2024 = 1.96%, SLR 30 Nov 2025 = 2.55%
- Up to 6 concurrent funds
- Not available for privatbostadsföretag or investmentföretag
### INK2 placement
- Avsättning: INK2R p. 3.22 (konto 8810/8811)
- Återföring: INK2R p. 3.21 (konto 8810/8819)
- Schablonintäkt: INK2S p. 4.6a (pure tax item, NOT booked in accounting)
- Balance: INK2R p. 2.29 (konto 211x-213x)
---
## 6. Överavskrivningar in INK2
Legal reference: IL 18 kap.
### Räkenskapsenlig avskrivning (requires skattemässig = bokförd, 18:14 §)
- **Huvudregeln / 30-regeln (18:13 §)**: max 30% of avskrivningsunderlag
- **Kompletteringsregeln / 20-regeln (18:17 §)**: 20% annual on anskaffningsvärde = full write-off in 5 years
- Methods combinable for different asset categories, not on same category in one year
### Restvärdesavskrivning (alternative)
- Max **25%** of avskrivningsunderlag
- Does NOT require matching between bokförda and skattemässiga values
### INK2 placement
- Förändring: INK2R p. 3.23 (konto 885x)
- Accumulated: INK2R p. 2.30 (konto 215x)
---
## 7. Koncernbidrag in INK2
Legal reference: IL 35 kap.
### Requirements (35:2-3 §§)
- >90% ownership
- Both parties skattskyldiga in Sweden
- For entire beskattningsår
### K2 treatment (booked through resultaträkning)
- Lämnade: INK2R p. 3.19 (konto 883x)
- Mottagna: INK2R p. 3.20 (konto 882x)
### K3/RFR 2 treatment (may be booked via equity)
- Lämnade: INK2S p. 4.4a
- Mottagna: INK2S p. 4.6c
**TRAP**: K2/K3 difference in booking method leads to double-reporting or missing amounts if INK2R/INK2S split is handled incorrectly.
---
## 8. Ränteavdragsbegränsningar (N9)
Legal reference: IL 24:24-29 §§.
- **EBITDA rule**: negative räntenetto limited to **30% of tax-EBITDA** (24:24 §)
- **Förenklingsregel**: **5 MSEK** negative net interest deductible without limitation
- Filing **N9** (SKV 2158) required if deducting under EBITDA rule or transferring/receiving räntenetto in a group
- Complex rules for koncernutjämning of negative räntenetto
---
## 9. Common errors and traps
### Critical errors (high risk for skattetillägg)
1. **Nedskrivning av finansiella anläggningstillgångar not reversed at p. 4.3b.** Nedskrivningar at p. 3.17 are almost never tax-deductible but frequently missed in skattemässig justering. Often large amounts. Skatteverket ställningstagande 2022-10-27 on befrielse from skattetillägg for this error.
2. **Aktiefållan at p. 4.7f.** Kapitalförluster on kapitalplaceringsaktier can only offset gains on similar instruments. Cannot be deducted against other income. Proper separation critical.
3. **Glömd schablonintäkt på periodiseringsfond at p. 4.6a.** Pure tax item, no bookkeeping entry. Frequently forgotten. Must be reported annually.
4. **Koncernbidrag K2/K3 inconsistency.** K2 flows through income statement; K3 may flow through equity. Leads to double-reporting or missing amounts if INK2R/INK2S split is wrong.
5. **Missing SLP underlag at INK2 p. 1.4.** Companies with pension costs frequently forget to calculate and report särskild löneskatt base (24.26% rate).
### Other common errors
- INK2R figures not matching årsredovisningen (rounding)
- Missing bilagor (INK2 without INK2R/INK2S makes declaration incomplete)
- Negative amounts in SRU files causing upload failures
- Ej avdragsgill representation not identified and added back at 4.3c
- Incorrect rollforward of underskott from prior years at 4.14
---
## 10. Filing deadlines
### General rule
Approximately 7 months after FY end.
### Calendar year companies (Dec 31 FY end)
Deadline: **1 augusti** (moved to next weekday if weekend/holiday).
- FY 2025: 3 augusti 2026 (1 Aug is Saturday)
### Other FY endings
| FY ending | Deadline |
|---|---|
| Jan-Apr 2025 | 1 december 2025 |
| May-Jun 2025 | 15 januari 2026 |
| Jul-Aug 2025 | 1 april 2026 |
| Sep-Dec 2025 | 3 augusti 2026 |
### Consequences of late filing
- Förseningsavgift: **6,250 SEK** per instance (up to 3, max **18,750 SEK** within 1 year)
- No declaration: Skatteverket may impose **skönstaxering** + **skattetillägg**
- F-skatt godkännande may be revoked
- Kostnadsränta accrues on unpaid tax
### Anstånd
Companies can apply for anstånd or **byråanstånd** through authorized redovisningsbyråer before deadline.
---
## IL chapter reference for INK2
| Area | IL chapter | Key provisions |
|---|---|---|
| Periodiseringsfond | 30 kap. | 30:3 (who), 30:5 (25%), 30:6a (schablonintäkt), 30:7 (6-year reversal) |
| Räkenskapsenlig avskrivning | 18 kap. | 18:13 (30-regeln), 18:17 (20-regeln), 18:14 (bokföringsöverensstämmelse) |
| Koncernbidrag | 35 kap. | 35:2-3 (förutsättningar, >90% ägande) |
| Ränteavdragsbegränsningar | 24 kap. | 24:24 (30% EBITDA / 5 MSEK förenklingsregel) |
| Ej avdragsgilla kostnader | 9 + 16 kap. | 9:2 (gåvor), 16:1 (avdragsrätt) |
| Underskottsavdrag | 40 kap. | 40:2 (huvudregel), 40:16 (beloppsspärr) |
| Näringsbetingade andelar | 24 + 25a kap. | Skattefri utdelning och kapitalvinst |
+10
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { eventBus } from '@/lib/events'
ensureInitialized()
@@ -49,6 +50,15 @@ export async function GET(
)
}
await eventBus.emit({
type: 'document.accessed',
payload: {
document: { id: doc.id, file_name: doc.file_name },
userId: user.id,
companyId,
},
})
return NextResponse.json({
data: {
...doc,
+3 -2
View File
@@ -31,10 +31,10 @@ export async function GET(request: Request) {
// Fetch up to 100 current-version documents, prioritizing unchecked/oldest
const { data: documents, error: fetchError } = await supabase
.from('document_attachments')
.select('id, user_id, storage_path, sha256_hash, file_name')
.select('id, user_id, company_id, storage_path, sha256_hash, file_name')
.eq('is_current_version', true)
.order('last_integrity_check_at', { ascending: true, nullsFirst: true })
.limit(100)
.limit(parseInt(process.env.DOCUMENT_VERIFY_BATCH_SIZE || '500', 10))
if (fetchError) {
console.error('[doc-verify-cron] Failed to fetch documents:', fetchError)
@@ -80,6 +80,7 @@ export async function GET(request: Request) {
// Log integrity failure to audit_log
await supabase.from('audit_log').insert({
user_id: doc.user_id,
company_id: doc.company_id,
action: 'INTEGRITY_FAILURE',
table_name: 'document_attachments',
record_id: doc.id,
@@ -70,27 +70,28 @@ describe('correctEntry', () => {
const correctedEntry = makeJournalEntry({ id: 'corrected-1', correction_of_id: 'orig-1' })
results = [
// 0: fetch original
// 0: fetch original (.single())
{ data: originalEntry, error: null },
// 1: insert reversal entry
// 1: insert reversal entry (.single())
{ data: reversalEntry, error: null },
// 2: insert reversal lines (thenable, no .single())
// 2: insert reversal lines (thenable)
{ data: null, error: null },
// 3: update reversal to posted (thenable)
{ data: null, error: null },
// 4: mark original as reversed (thenable)
{ data: null, error: null },
// 5: fetch accounts for corrected lines
// -- getNextVoucherNumber increments resultIdx --
// 4: fetch accounts for corrected lines (thenable)
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null },
// 6: insert corrected entry
// 5: insert corrected entry (.single())
{ data: correctedEntry, error: null },
// 7: insert corrected lines (thenable)
// 6: insert corrected lines (thenable)
{ data: null, error: null },
// 8: update corrected to posted (thenable)
// 7: update corrected to posted (thenable)
{ data: null, error: null },
// 9: fetch final reversal
// 8: CAS update original to reversed (thenable, needs array for .length check)
{ data: [{ id: 'orig-1' }], error: null },
// 9: fetch final reversal (.single())
{ data: { ...reversalEntry, lines: [] }, error: null },
// 10: fetch final corrected
// 10: fetch final corrected (.single())
{ data: { ...correctedEntry, lines: correctedLines }, error: null },
]
}
@@ -128,6 +129,69 @@ describe('correctEntry', () => {
).rejects.toThrow('not balanced')
})
it('cancels both entries on concurrent reversal (CAS guard)', async () => {
const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' })
const correctedEntry = makeJournalEntry({ id: 'corrected-1', correction_of_id: 'orig-1' })
results = [
{ data: originalEntry, error: null }, // 0: fetch original
{ data: reversalEntry, error: null }, // 1: insert reversal
{ data: null, error: null }, // 2: insert reversal lines
{ data: null, error: null }, // 3: post reversal
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 4: accounts
{ data: correctedEntry, error: null }, // 5: insert corrected
{ data: null, error: null }, // 6: insert corrected lines
{ data: null, error: null }, // 7: post corrected
{ data: [], error: null }, // 8: CAS fails — empty array
{ data: null, error: null }, // 9: cancelEntry reversal update
{ data: null, error: null }, // 10: cancelEntry reversal lines delete
{ data: null, error: null }, // 11: cancelEntry corrected update
{ data: null, error: null }, // 12: cancelEntry corrected lines delete
]
const supabase = makeClient()
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines)
).rejects.toThrow('already reversed')
})
it('cancels reversal when corrected entry creation fails', async () => {
const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' })
results = [
{ data: originalEntry, error: null }, // 0: fetch original
{ data: reversalEntry, error: null }, // 1: insert reversal
{ data: null, error: null }, // 2: insert reversal lines
{ data: null, error: null }, // 3: post reversal
{ data: [], error: null }, // 4: accounts
{ data: null, error: { message: 'DB error' } }, // 5: insert corrected FAILS
{ data: null, error: null }, // 6: cancelEntry reversal update
{ data: null, error: null }, // 7: cancelEntry reversal lines delete
]
const supabase = makeClient()
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines)
).rejects.toThrow('Failed to create corrected entry')
})
it('cancels reversal entry when reversal lines fail', async () => {
const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' })
results = [
{ data: originalEntry, error: null }, // 0: fetch original
{ data: reversalEntry, error: null }, // 1: insert reversal
{ data: null, error: { message: 'line error' } }, // 2: insert reversal lines FAILS
{ data: null, error: null }, // 3: cancelEntry update
{ data: null, error: null }, // 4: cancelEntry lines delete
]
const supabase = makeClient()
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines)
).rejects.toThrow('Failed to create reversal lines')
})
it('emits journal_entry.corrected event', async () => {
setupResults()
+52 -25
View File
@@ -17,6 +17,28 @@ import { validateBalance, getNextVoucherNumber, getSwedishLocalDate } from '@/li
* 3. Link all three via reverses_id, reversed_by_id, correction_of_id
*/
/**
* Cancel a journal entry and delete its lines.
* Uses status='cancelled' instead of DELETE (DB trigger blocks all DELETEs).
* Works for both draft→cancelled and posted→cancelled transitions.
*/
async function cancelEntry(supabase: SupabaseClient, entryId: string): Promise<void> {
const { error: statusErr } = await supabase
.from('journal_entries')
.update({ status: 'cancelled' })
.eq('id', entryId)
if (statusErr) {
console.error(`[storno] cancelEntry: failed to cancel ${entryId}:`, statusErr.message)
}
const { error: linesErr } = await supabase
.from('journal_entry_lines')
.delete()
.eq('journal_entry_id', entryId)
if (linesErr) {
console.error(`[storno] cancelEntry: failed to delete lines for ${entryId}:`, linesErr.message)
}
}
/**
* Correct an existing posted journal entry using the storno method.
*
@@ -106,7 +128,7 @@ export async function correctEntry(
.insert(reversalLineInserts)
if (reversalLinesError) {
await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
await cancelEntry(supabase, reversalEntry.id)
throw new Error(`Failed to create reversal lines: ${reversalLinesError.message}`)
}
@@ -117,32 +139,17 @@ export async function correctEntry(
.eq('id', reversalEntry.id)
if (postReversalError) {
await cancelEntry(supabase, reversalEntry.id)
throw new Error(`Failed to post reversal entry: ${postReversalError.message}`)
}
// Mark original as reversed
await supabase
.from('journal_entries')
.update({
status: 'reversed',
reversed_by_id: reversalEntry.id,
})
.eq('id', originalEntryId)
// NOTE: Original entry is NOT marked as 'reversed' here. We defer that
// until both the reversal and corrected entries are successfully posted.
// This avoids the impossible reversed→posted rollback if step 2 fails.
// ===== Step 2: Create corrected entry =====
// If anything in this step fails, we must roll back the reversal from step 1
// to avoid leaving the ledger in an inconsistent state.
async function rollbackReversal() {
// Restore original entry to 'posted' status
await supabase
.from('journal_entries')
.update({ status: 'posted', reversed_by_id: null })
.eq('id', originalEntryId)
// Delete the reversal entry (it was just created, safe to remove since
// the DB trigger allows deleting draft entries and we need to clean up)
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
}
// If anything in this step fails, cancel the reversal entry.
// The original entry was never modified, so no rollback needed.
let correctedEntry: typeof reversalEntry
@@ -214,7 +221,7 @@ export async function correctEntry(
.insert(correctedLineInserts)
if (correctedLinesError) {
await supabase.from('journal_entries').delete().eq('id', correctedEntry.id)
await cancelEntry(supabase, correctedEntry.id)
throw new Error(`Failed to create corrected lines: ${correctedLinesError.message}`)
}
@@ -225,14 +232,34 @@ export async function correctEntry(
.eq('id', correctedEntry.id)
if (postCorrectedError) {
await cancelEntry(supabase, correctedEntry.id)
throw new Error(`Failed to post corrected entry: ${postCorrectedError.message}`)
}
} catch (err) {
// Roll back the reversal to restore ledger consistency
await rollbackReversal()
// Cancel the reversal entry (posted → cancelled). Original was never
// modified so no rollback needed — it's still 'posted'.
await cancelEntry(supabase, reversalEntry.id)
throw err
}
// ===== Mark original as reversed (CAS guard: only if still 'posted') =====
const { data: updatedOriginal, error: casError } = await supabase
.from('journal_entries')
.update({
status: 'reversed',
reversed_by_id: reversalEntry.id,
})
.eq('id', originalEntryId)
.eq('status', 'posted')
.select('id')
if (casError || !updatedOriginal || updatedOriginal.length === 0) {
// Concurrent reversal beat us — cancel both our entries
await cancelEntry(supabase, reversalEntry.id)
await cancelEntry(supabase, correctedEntry!.id)
throw new Error('Entry was already reversed by a concurrent operation')
}
// ===== Step 3: Fetch complete entries =====
const { data: finalReversal } = await supabase
.from('journal_entries')
+1
View File
@@ -13,6 +13,7 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [
'journal_entry.committed',
'journal_entry.corrected',
'document.uploaded',
'document.accessed',
'invoice.created',
'invoice.sent',
'credit_note.created',
+1
View File
@@ -23,6 +23,7 @@ export type CoreEvent =
| { type: 'journal_entry.corrected'; payload: { original: JournalEntry; storno: JournalEntry; corrected: JournalEntry; userId: string; companyId: string } }
// Documents
| { type: 'document.uploaded'; payload: { document: DocumentAttachment; userId: string; companyId: string } }
| { type: 'document.accessed'; payload: { document: { id: string; file_name: string }; userId: string; companyId: string } }
// Invoicing
| { type: 'invoice.created'; payload: { invoice: Invoice; userId: string; companyId: string } }
| { type: 'invoice.sent'; payload: { invoice: Invoice; userId: string; companyId: string } }
+82 -8
View File
@@ -16,8 +16,16 @@ export interface FullArchiveOptions {
}
interface DocumentManifestEntry {
document_id: string
file_name: string
storage_path: string
sha256_hash: string
journal_entry_id: string | null
version: number
digitization_date: string | null
upload_source: string | null
mime_type: string | null
file_size_bytes: number | null
status: 'downloaded' | 'missing' | 'error'
error?: string
}
@@ -110,7 +118,7 @@ export async function generateFullArchive(
// Fetch document attachments linked to journal entries in this period
const { data: documents } = await supabase
.from('document_attachments')
.select('id, file_name, storage_path, journal_entry_id')
.select('id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
@@ -129,6 +137,19 @@ export async function generateFullArchive(
)
for (const doc of periodDocuments) {
const baseManifest = {
document_id: doc.id,
file_name: doc.file_name,
storage_path: doc.storage_path,
sha256_hash: doc.sha256_hash,
journal_entry_id: doc.journal_entry_id,
version: doc.version,
digitization_date: doc.digitization_date,
upload_source: doc.upload_source,
mime_type: doc.mime_type,
file_size_bytes: doc.file_size_bytes,
}
try {
const { data: fileData, error } = await supabase.storage
.from('documents')
@@ -136,8 +157,7 @@ export async function generateFullArchive(
if (error || !fileData) {
manifest.push({
file_name: doc.file_name,
storage_path: doc.storage_path,
...baseManifest,
status: 'error',
error: error?.message || 'Download returned no data',
})
@@ -145,16 +165,16 @@ export async function generateFullArchive(
}
const buffer = await fileData.arrayBuffer()
dokument.file(doc.file_name, buffer)
// Prefix with document ID to prevent duplicate filename collisions
const zipFileName = `${doc.id}_${doc.file_name}`
dokument.file(zipFileName, buffer)
manifest.push({
file_name: doc.file_name,
storage_path: doc.storage_path,
...baseManifest,
status: 'downloaded',
})
} catch (err) {
manifest.push({
file_name: doc.file_name,
storage_path: doc.storage_path,
...baseManifest,
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error',
})
@@ -187,5 +207,59 @@ export async function generateFullArchive(
revision.file('behandlingshistorik.json', JSON.stringify(allAuditEntries, null, 2))
// 5. Systemdokumentation (BFNAR 2013:2 kap 8)
const [accountsResult, voucherSeriesResult] = await Promise.all([
supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_type, is_active')
.eq('company_id', companyId)
.order('account_number'),
supabase
.from('voucher_sequences')
.select('voucher_series, last_number')
.eq('company_id', companyId)
.eq('fiscal_period_id', period_id),
])
const systemdokumentation = {
system: {
name: 'gnubok',
description: 'Bokforingssystem for enskild firma och aktiebolag',
url: process.env.NEXT_PUBLIC_APP_URL || '',
},
kontoplan: {
standard: 'BAS 2026',
accounts: accountsResult.data || [],
},
verifikationsserier: (voucherSeriesResult.data || []).map((vs: { voucher_series: string; last_number: number }) => ({
serie: vs.voucher_series,
senaste_nummer: vs.last_number,
})),
behorighetskontroll: {
description: 'Rollbaserad atkomstkontroll med owner/admin/member/viewer',
mfa_stod: true,
rls_aktiv: true,
},
arkivering: {
lagringstid_ar: 7,
format: 'WORM (Write Once, Read Many)',
integritetskontroll: 'SHA-256 hashning vid uppladdning, regelbunden verifiering',
lagringsplats: 'Supabase Storage (krypterad)',
},
integrationer: {
bank: 'Enable Banking (PSD2)',
email: 'Resend',
export_format: 'SIE4',
},
generated_at: new Date().toISOString(),
fiscal_period: {
id: period.id,
start: period.period_start,
end: period.period_end,
},
}
revision.file('systemdokumentation.json', JSON.stringify(systemdokumentation, null, 2))
return zip.generateAsync({ type: 'arraybuffer' })
}
@@ -0,0 +1,15 @@
-- Add INTEGRITY_FAILURE to audit_log action CHECK constraint
-- The verify cron (app/api/documents/verify/cron) inserts INTEGRITY_FAILURE
-- but the original CHECK constraint in migration 014 did not include it,
-- causing all integrity failure logging to silently fail.
ALTER TABLE public.audit_log DROP CONSTRAINT audit_log_action_check;
ALTER TABLE public.audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN (
'INSERT', 'UPDATE', 'DELETE',
'COMMIT', 'REVERSE', 'CORRECT',
'LOCK_PERIOD', 'CLOSE_PERIOD',
'DOCUMENT_DELETE_BLOCKED', 'RETENTION_BLOCK',
'SECURITY_EVENT',
'INTEGRITY_FAILURE'
));
@@ -0,0 +1,116 @@
-- Document version chain: prev_version_hash column + create_document_version RPC
-- Fixes the non-functional document versioning (migration 023 was a placeholder).
-- The createNewVersion() in lib/core/documents/document-service.ts calls this RPC.
-- 1. Add prev_version_hash column for cryptographic version chain
ALTER TABLE public.document_attachments
ADD COLUMN IF NOT EXISTS prev_version_hash text;
-- 2. Atomic version creation RPC
-- Row-locks the current version, inserts a new version with hash chain,
-- and marks the old version as superseded — all in one transaction.
CREATE OR REPLACE FUNCTION public.create_document_version(
p_user_id uuid,
p_original_doc_id uuid,
p_storage_path text,
p_file_name text,
p_file_size_bytes bigint,
p_mime_type text,
p_sha256_hash text
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_current document_attachments%ROWTYPE;
v_new_id uuid;
v_root_id uuid;
v_next_version integer;
BEGIN
-- Lock the current version row to prevent concurrent versioning
SELECT * INTO v_current
FROM public.document_attachments
WHERE id = p_original_doc_id
AND is_current_version = true
FOR UPDATE;
IF v_current IS NULL THEN
RAISE EXCEPTION 'Document % not found or is not the current version', p_original_doc_id;
END IF;
-- Determine root document and next version number
v_root_id := COALESCE(v_current.original_id, v_current.id);
v_next_version := v_current.version + 1;
-- Insert new version with hash chain link
INSERT INTO public.document_attachments (
user_id, company_id, storage_path, file_name, file_size_bytes,
mime_type, sha256_hash, version, original_id, is_current_version,
uploaded_by, upload_source, digitization_date,
journal_entry_id, journal_entry_line_id, prev_version_hash
) VALUES (
p_user_id, v_current.company_id, p_storage_path, p_file_name,
p_file_size_bytes, p_mime_type, p_sha256_hash, v_next_version,
v_root_id, true, p_user_id, v_current.upload_source, now(),
v_current.journal_entry_id, v_current.journal_entry_line_id,
v_current.sha256_hash -- cryptographic link to previous version
)
RETURNING id INTO v_new_id;
-- Mark old version as superseded
UPDATE public.document_attachments
SET is_current_version = false,
superseded_by_id = v_new_id
WHERE id = p_original_doc_id;
RETURN v_new_id;
END;
$$;
-- 3. Version chain validation function
-- Walks the version chain from newest to oldest and verifies each
-- prev_version_hash matches the prior version's sha256_hash.
CREATE OR REPLACE FUNCTION public.validate_version_chain(p_document_id uuid)
RETURNS TABLE(version integer, document_id uuid, hash_valid boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_root_id uuid;
BEGIN
-- Find root document
SELECT COALESCE(da.original_id, da.id) INTO v_root_id
FROM public.document_attachments da
WHERE da.id = p_document_id;
IF v_root_id IS NULL THEN
RAISE EXCEPTION 'Document % not found', p_document_id;
END IF;
-- Walk chain and verify hashes
RETURN QUERY
WITH chain AS (
SELECT
da.id AS doc_id,
da.version AS ver,
da.sha256_hash,
da.prev_version_hash,
LAG(da.sha256_hash) OVER (ORDER BY da.version) AS expected_prev_hash
FROM public.document_attachments da
WHERE da.id = v_root_id OR da.original_id = v_root_id
ORDER BY da.version
)
SELECT
chain.ver,
chain.doc_id,
CASE
WHEN chain.ver = 1 THEN chain.prev_version_hash IS NULL
ELSE chain.prev_version_hash IS NOT DISTINCT FROM chain.expected_prev_hash
END AS hash_valid
FROM chain
ORDER BY chain.ver;
END;
$$;
@@ -0,0 +1,57 @@
-- Enforce document metadata immutability for documents linked to committed entries
-- BFL 7 kap requires verifikation underlag to be immutable once committed.
-- Existing triggers only block DELETE — this blocks metadata UPDATE.
CREATE OR REPLACE FUNCTION public.enforce_document_metadata_immutability()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entry_status text;
BEGIN
-- Only enforce on documents already linked to a journal entry
IF OLD.journal_entry_id IS NULL THEN
RETURN NEW;
END IF;
SELECT status INTO v_entry_status
FROM public.journal_entries
WHERE id = OLD.journal_entry_id;
-- Only enforce for committed (posted/reversed) entries
IF v_entry_status IS NULL OR v_entry_status NOT IN ('posted', 'reversed') THEN
RETURN NEW;
END IF;
-- Block changes to immutable fields
-- Allowed: last_integrity_check_at (cron), updated_at (auto-trigger),
-- superseded_by_id (versioning), prev_version_hash (versioning),
-- journal_entry_id/journal_entry_line_id (linking)
IF NEW.file_name IS DISTINCT FROM OLD.file_name
OR NEW.storage_path IS DISTINCT FROM OLD.storage_path
OR NEW.file_size_bytes IS DISTINCT FROM OLD.file_size_bytes
OR NEW.mime_type IS DISTINCT FROM OLD.mime_type
OR NEW.sha256_hash IS DISTINCT FROM OLD.sha256_hash
OR NEW.upload_source IS DISTINCT FROM OLD.upload_source
OR NEW.digitization_date IS DISTINCT FROM OLD.digitization_date
OR NEW.uploaded_by IS DISTINCT FROM OLD.uploaded_by
OR NEW.version IS DISTINCT FROM OLD.version
OR NEW.original_id IS DISTINCT FROM OLD.original_id
THEN
-- Log the blocked attempt
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'SECURITY_EVENT', 'document_attachments', OLD.id,
'Blocked metadata modification of document linked to ' || v_entry_status || ' entry ' || OLD.journal_entry_id);
RAISE EXCEPTION 'Cannot modify metadata of document linked to a % journal entry (BFL 7 kap)', v_entry_status;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_document_metadata_immutability
BEFORE UPDATE ON public.document_attachments
FOR EACH ROW EXECUTE FUNCTION public.enforce_document_metadata_immutability();
@@ -0,0 +1,67 @@
-- Audit all document deletions, including unlinked documents
-- Previously, block_document_deletion() only logged linked document deletion attempts.
-- Unlinked documents were deleted without any trace in the audit log.
CREATE OR REPLACE FUNCTION public.block_document_deletion()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entry_status text;
v_retention_expires date;
BEGIN
-- Always log document deletion attempts (linked and unlinked)
IF OLD.journal_entry_id IS NULL THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description, old_state)
VALUES (
OLD.user_id, OLD.company_id, 'DELETE', 'document_attachments', OLD.id,
'Deleted unlinked document "' || OLD.file_name || '"',
to_jsonb(OLD)
);
-- Allow deletion of unlinked documents
RETURN OLD;
END IF;
-- Check if linked to a committed journal entry
SELECT je.status INTO v_entry_status
FROM public.journal_entries je
WHERE je.id = OLD.journal_entry_id;
IF v_entry_status IN ('posted', 'reversed') THEN
-- Log the blocked attempt
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'DOCUMENT_DELETE_BLOCKED', 'document_attachments', OLD.id,
'Attempted deletion of document linked to ' || v_entry_status || ' journal entry ' || OLD.journal_entry_id);
RAISE EXCEPTION 'Cannot delete document linked to a % journal entry (Bokforingslagen)',
v_entry_status;
END IF;
-- Check retention window
SELECT fp.retention_expires_at INTO v_retention_expires
FROM public.journal_entries je
JOIN public.fiscal_periods fp ON fp.id = je.fiscal_period_id
WHERE je.id = OLD.journal_entry_id;
IF v_retention_expires IS NOT NULL AND v_retention_expires > CURRENT_DATE THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'RETENTION_BLOCK', 'document_attachments', OLD.id,
'Attempted deletion within retention period (expires ' || v_retention_expires || ')');
RAISE EXCEPTION 'Cannot delete document within 7-year retention period (expires %)',
v_retention_expires;
END IF;
-- Log deletion of linked-but-not-committed documents
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description, old_state)
VALUES (
OLD.user_id, OLD.company_id, 'DELETE', 'document_attachments', OLD.id,
'Deleted document "' || OLD.file_name || '" linked to draft entry ' || OLD.journal_entry_id,
to_jsonb(OLD)
);
RETURN OLD;
END;
$$;
@@ -0,0 +1,35 @@
-- Fix: add posted → cancelled transition for orphaned entry cleanup.
-- The live trigger is missing this transition due to migration 20260319000001
-- being edited after it was applied. engine.ts reverseEntry and storno-service
-- need posted → cancelled for CAS guard cleanup of orphaned concurrent reversals.
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
-- No exemption for drafts: varaktighet applies from insertion.
-- Application code uses status='cancelled' instead of DELETE.
RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.',
OLD.id, OLD.status;
END IF;
-- Draft can transition to draft (update fields), posted, or cancelled
IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN
RETURN NEW;
END IF;
-- Posted can transition to reversed (storno) or cancelled (orphaned cleanup)
IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN
IF NEW.status = 'reversed' THEN
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
END IF;
END IF;
RETURN NEW;
END IF;
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.',
OLD.status, OLD.id;
END; $$;
+1 -1
View File
@@ -18,7 +18,7 @@
},
{
"path": "/api/documents/verify/cron",
"schedule": "0 3 * * 0"
"schedule": "0 3 * * *"
},
{
"path": "/api/sandbox/cleanup/cron",