From b484e9a7b4b112a3f643290f023991c76e16fa10 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:12:03 +0200 Subject: [PATCH] fix: Swedish VAT/SIE compliance, storno hardening, document integrity (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * fix: address Greptile review — update ruta49 JSDoc, use null sentinel in error map Co-Authored-By: Claude Opus 4.6 (1M context) * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../swedish-financial-reporting/SKILL.md | 55 ++++ .../references/arsredovisning-structure.md | 207 +++++++++++++ .../references/bolagsverket-filing.md | 152 ++++++++++ .../references/ink2-form-logic.md | 285 ++++++++++++++++++ app/api/documents/[id]/route.ts | 10 + app/api/documents/verify/cron/route.ts | 5 +- .../__tests__/storno-service.test.ts | 86 +++++- lib/core/bookkeeping/storno-service.ts | 77 +++-- lib/events/handlers/event-log-handler.ts | 1 + lib/events/types.ts | 1 + lib/reports/full-archive-export.ts | 90 +++++- ...0409130000_add_integrity_audit_actions.sql | 15 + .../20260409130100_document_version_chain.sql | 116 +++++++ ...enforce_document_metadata_immutability.sql | 57 ++++ ...409130300_audit_all_document_deletions.sql | 67 ++++ ...0605_fix_immutability_posted_cancelled.sql | 35 +++ vercel.json | 2 +- 17 files changed, 1214 insertions(+), 47 deletions(-) create mode 100644 .claude/skills/swedish-financial-reporting/SKILL.md create mode 100644 .claude/skills/swedish-financial-reporting/references/arsredovisning-structure.md create mode 100644 .claude/skills/swedish-financial-reporting/references/bolagsverket-filing.md create mode 100644 .claude/skills/swedish-financial-reporting/references/ink2-form-logic.md create mode 100644 supabase/migrations/20260409130000_add_integrity_audit_actions.sql create mode 100644 supabase/migrations/20260409130100_document_version_chain.sql create mode 100644 supabase/migrations/20260409130200_enforce_document_metadata_immutability.sql create mode 100644 supabase/migrations/20260409130300_audit_all_document_deletions.sql create mode 100644 supabase/migrations/20260409130605_fix_immutability_posted_cancelled.sql diff --git a/.claude/skills/swedish-financial-reporting/SKILL.md b/.claude/skills/swedish-financial-reporting/SKILL.md new file mode 100644 index 00000000..2222a74a --- /dev/null +++ b/.claude/skills/swedish-financial-reporting/SKILL.md @@ -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 | \ No newline at end of file diff --git a/.claude/skills/swedish-financial-reporting/references/arsredovisning-structure.md b/.claude/skills/swedish-financial-reporting/references/arsredovisning-structure.md new file mode 100644 index 00000000..498969e0 --- /dev/null +++ b/.claude/skills/swedish-financial-reporting/references/arsredovisning-structure.md @@ -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. \ No newline at end of file diff --git a/.claude/skills/swedish-financial-reporting/references/bolagsverket-filing.md b/.claude/skills/swedish-financial-reporting/references/bolagsverket-filing.md new file mode 100644 index 00000000..c8dfd5d5 --- /dev/null +++ b/.claude/skills/swedish-financial-reporting/references/bolagsverket-filing.md @@ -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 | \ No newline at end of file diff --git a/.claude/skills/swedish-financial-reporting/references/ink2-form-logic.md b/.claude/skills/swedish-financial-reporting/references/ink2-form-logic.md new file mode 100644 index 00000000..8f66e5b4 --- /dev/null +++ b/.claude/skills/swedish-financial-reporting/references/ink2-form-logic.md @@ -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 | \ No newline at end of file diff --git a/app/api/documents/[id]/route.ts b/app/api/documents/[id]/route.ts index aa0cdd74..dca1e9eb 100644 --- a/app/api/documents/[id]/route.ts +++ b/app/api/documents/[id]/route.ts @@ -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, diff --git a/app/api/documents/verify/cron/route.ts b/app/api/documents/verify/cron/route.ts index e69b23d5..90dfb6f0 100644 --- a/app/api/documents/verify/cron/route.ts +++ b/app/api/documents/verify/cron/route.ts @@ -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, diff --git a/lib/core/bookkeeping/__tests__/storno-service.test.ts b/lib/core/bookkeeping/__tests__/storno-service.test.ts index e5a82fe2..b8bc7b1f 100644 --- a/lib/core/bookkeeping/__tests__/storno-service.test.ts +++ b/lib/core/bookkeeping/__tests__/storno-service.test.ts @@ -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() diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts index d4b5a540..6313c2da 100644 --- a/lib/core/bookkeeping/storno-service.ts +++ b/lib/core/bookkeeping/storno-service.ts @@ -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 { + 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') diff --git a/lib/events/handlers/event-log-handler.ts b/lib/events/handlers/event-log-handler.ts index da39365d..abadcd83 100644 --- a/lib/events/handlers/event-log-handler.ts +++ b/lib/events/handlers/event-log-handler.ts @@ -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', diff --git a/lib/events/types.ts b/lib/events/types.ts index 9a8c7beb..4bbbb6f1 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -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 } } diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 18a84538..9c3921c6 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -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' }) } diff --git a/supabase/migrations/20260409130000_add_integrity_audit_actions.sql b/supabase/migrations/20260409130000_add_integrity_audit_actions.sql new file mode 100644 index 00000000..b4199454 --- /dev/null +++ b/supabase/migrations/20260409130000_add_integrity_audit_actions.sql @@ -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' +)); diff --git a/supabase/migrations/20260409130100_document_version_chain.sql b/supabase/migrations/20260409130100_document_version_chain.sql new file mode 100644 index 00000000..831aacea --- /dev/null +++ b/supabase/migrations/20260409130100_document_version_chain.sql @@ -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; +$$; diff --git a/supabase/migrations/20260409130200_enforce_document_metadata_immutability.sql b/supabase/migrations/20260409130200_enforce_document_metadata_immutability.sql new file mode 100644 index 00000000..6d717b40 --- /dev/null +++ b/supabase/migrations/20260409130200_enforce_document_metadata_immutability.sql @@ -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(); diff --git a/supabase/migrations/20260409130300_audit_all_document_deletions.sql b/supabase/migrations/20260409130300_audit_all_document_deletions.sql new file mode 100644 index 00000000..fa855411 --- /dev/null +++ b/supabase/migrations/20260409130300_audit_all_document_deletions.sql @@ -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; +$$; diff --git a/supabase/migrations/20260409130605_fix_immutability_posted_cancelled.sql b/supabase/migrations/20260409130605_fix_immutability_posted_cancelled.sql new file mode 100644 index 00000000..8836ee3f --- /dev/null +++ b/supabase/migrations/20260409130605_fix_immutability_posted_cancelled.sql @@ -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; $$; diff --git a/vercel.json b/vercel.json index 3a2c9383..498da06a 100644 --- a/vercel.json +++ b/vercel.json @@ -18,7 +18,7 @@ }, { "path": "/api/documents/verify/cron", - "schedule": "0 3 * * 0" + "schedule": "0 3 * * *" }, { "path": "/api/sandbox/cleanup/cron",