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:
co-authored by
Claude Opus 4.6
parent
6ccd4f429c
commit
b484e9a7b4
@@ -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 |
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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
@@ -18,7 +18,7 @@
|
||||
},
|
||||
{
|
||||
"path": "/api/documents/verify/cron",
|
||||
"schedule": "0 3 * * 0"
|
||||
"schedule": "0 3 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/sandbox/cleanup/cron",
|
||||
|
||||
Reference in New Issue
Block a user