feat: add INK2 declaration, full archive export, AI consent gate, fix VAT declaration rutor

- Fix VAT declaration ruta mappings to match SKV 4700 form correctly
  (ruta 05 = total taxable sales, ruta 10/11/12 = output VAT per rate)
- Add INK2 declaration report for aktiebolag with SRU export
- Add full archive ZIP export for 7-year retention compliance
- Add AI consent gate requiring user approval before AI extension API calls
- Add DPA and privacy policy public pages
- Add audit trail API routes
- Update VAT registration threshold from 80k to 120k kr in onboarding
- Update CLAUDE.md documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-05 14:20:47 +01:00
parent 50bf7b3b0f
commit 29240738fa
32 changed files with 3496 additions and 97 deletions
+20 -12
View File
@@ -6,7 +6,7 @@ erp-base is a Swedish-focused accounting SaaS for sole traders (enskild firma) a
**Tech stack**: Next.js 16 (App Router), React 19, TypeScript (strict), Supabase (PostgreSQL + RLS + magic link auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting.
**Integrations**: Enable Banking (PSD2), Anthropic SDK, LangChain, OpenAI (embeddings), Resend (email), web-push (VAPID).
**Integrations**: Enable Banking (PSD2), Anthropic SDK, LangChain, OpenAI (embeddings), Resend (email), JSZip (archive export).
**Path alias**: `@/*` maps to the project root. **Language**: All code, comments, and commit messages in English.
@@ -34,7 +34,7 @@ app/
(dashboard)/ Authenticated routes (invoices, customers, transactions,
bookkeeping, reports, suppliers, supplier-invoices,
receipts, deadlines, settings, help, import, extensions)
(public)/ Public invoice action links (no auth)
(public)/ Public invoice action links (no auth), DPA, privacy policy
api/ API routes organized by domain
components/
@@ -67,12 +67,14 @@ lib/
email/ EmailService interface + NoopEmailService default
events/ Event bus (bus.ts, types.ts) — singleton, core emits, extensions subscribe
extensions/ Extension system (loader, registry, types, hooks, context-factory)
ai-consent.ts AI consent gate for extensions using third-party AI providers
_generated/ Code-generated files (DO NOT EDIT)
import/ SIE parser, bank file parser (10 Swedish bank formats)
invoices/ VAT rules, invoice matching, PDF template, reminders
reconciliation/ Bank reconciliation engine (4-pass matching)
reports/ Financial reports (trial balance, income statement, balance sheet,
VAT declaration, SIE export, general ledger, NE-bilaga, SRU export)
VAT declaration, SIE export, general ledger, NE-bilaga, INK2, SRU export,
full archive ZIP export)
supabase/ Client setup (client.ts = browser, server.ts = server)
tax/ Tax calculations, deadlines, Swedish holidays
vat/ VIES validation, moms box mapping
@@ -92,7 +94,8 @@ extensions.config.json Extension opt-in configuration
- **Event bus** (`lib/events/bus.ts`) is a module-level singleton. Handlers run via `Promise.allSettled`.
- **Supabase clients**: browser (`lib/supabase/client.ts`), server with cookies (`createClient()` from `server.ts`), service role (`createServiceClient()`).
- **Extension system**: Opt-in via `extensions.config.json`. Core builds and runs with zero extensions.
- **NE-bilaga and SRU export** are core reports (in `lib/reports/`), not extensions.
- **NE-bilaga, INK2 declaration, SRU export, and full archive export** are core reports (in `lib/reports/`), not extensions.
- **AI consent gate** (`lib/extensions/ai-consent.ts`): AI extensions (`receipt-ocr`, `ai-categorization`, `ai-chat`) require user consent before API calls. The extension catch-all route checks consent and returns `403 AI_CONSENT_REQUIRED` if missing.
---
@@ -128,6 +131,19 @@ The engine (`lib/bookkeeping/engine.ts`) is the most critical system. All accoun
Invoice items support individual `vat_rate` values (mixed-rate invoices). `generatePerRateLines()` in `invoice-entries.ts` groups by rate. Use `getAvailableVatRates(customerType, vatNumberValidated)` from `lib/invoices/vat-rules.ts`.
### VAT Declaration Rutor (SKV 4700)
The `VatDeclarationRutor` type maps to the Swedish tax authority's momsdeklaration form:
- **Ruta 05**: Momspliktig försäljning — total domestic taxable sales (all rates combined, from 3001+3002+3003)
- **Ruta 06/07**: Unused (momspliktiga uttag / vinstmarginalbeskattning), always 0
- **Ruta 10/11/12**: Utgående moms 25%/12%/6% — output VAT per rate (from 2611/2621/2631)
- **Ruta 39/40**: EU services / Export (from 3308/3305)
- **Ruta 48**: Ingående moms — input VAT (from 2641/2645)
- **Ruta 49**: Moms att betala/återfå = (ruta 10 + 11 + 12) - ruta 48
The `VatDeclaration.breakdown.invoices` also includes `base25`/`base12`/`base6` for per-rate revenue breakdown in the UI.
### Bank Reconciliation
`lib/reconciliation/bank-reconciliation.ts` — 4-pass matching on account 1930:
@@ -174,7 +190,6 @@ Extensions are opt-in plugins controlled by `extensions.config.json`. Core build
| `receipt-ocr` | import | `ANTHROPIC_API_KEY` |
| `ai-categorization` | operations | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` |
| `ai-chat` | operations | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` |
| `push-notifications` | operations | VAPID keys |
| `invoice-inbox` | import | `ANTHROPIC_API_KEY` |
| `calendar` | operations | — |
| `enable-banking` | import | Enable Banking keys |
@@ -331,13 +346,6 @@ export async function POST(request: Request) {
---
## Type System
- All shared types in `types/index.ts` (single source of truth). Import via `import type { T } from '@/types'`
- Event types in `lib/events/types.ts`
---
## Skills, Git & CI
**Skills**: Always use `/frontend-design` for new UI. Use `langchain` for AI features. Use `vercel:deploy` for deployment.
+35 -14
View File
@@ -9,6 +9,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Download, AlertCircle, ChevronDown, ChevronRight } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView'
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart'
import { VatCompositionChart } from '@/components/reports/VatCompositionChart'
@@ -60,6 +61,7 @@ export default function ReportsPage() {
}
const isEnskildFirma = entityType === 'enskild_firma'
const isAktiebolag = entityType === 'aktiebolag'
return (
<div className="space-y-6">
@@ -135,6 +137,11 @@ export default function ReportsPage() {
NE-bilaga
</TabsTrigger>
)}
{isAktiebolag && (
<TabsTrigger value="ink2-declaration" className="w-full justify-start">
INK2
</TabsTrigger>
)}
</TabsList>
</div>
@@ -189,6 +196,11 @@ export default function ReportsPage() {
<NEDeclarationView periodId={selectedPeriod} />
</TabsContent>
)}
{isAktiebolag && (
<TabsContent value="ink2-declaration">
<INK2DeclarationView periodId={selectedPeriod} />
</TabsContent>
)}
<TabsContent value="huvudbok">
<GeneralLedgerView periodId={selectedPeriod} />
</TabsContent>
@@ -828,23 +840,32 @@ function VatDeclarationView() {
<h4 className="font-semibold mb-3">Utgående moms (försäljning)</h4>
<table className="w-full text-sm">
<tbody>
{data.rutor.ruta05 > 0 && (
<tr className="border-b">
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">05</span>
Momspliktig försäljning
</td>
<td className="py-2 text-right">{formatAmount(data.rutor.ruta05)} kr</td>
</tr>
)}
<VatRutaRow
ruta="05"
label="Moms 25%"
amount={data.rutor.ruta05}
baseAmount={data.rutor.ruta10}
ruta="10"
label="Utgående moms 25%"
amount={data.rutor.ruta10}
baseAmount={data.breakdown.invoices.base25}
/>
<VatRutaRow
ruta="06"
label="Moms 12%"
amount={data.rutor.ruta06}
baseAmount={data.rutor.ruta11}
ruta="11"
label="Utgående moms 12%"
amount={data.rutor.ruta11}
baseAmount={data.breakdown.invoices.base12}
/>
<VatRutaRow
ruta="07"
label="Moms 6%"
amount={data.rutor.ruta07}
baseAmount={data.rutor.ruta12}
ruta="12"
label="Utgående moms 6%"
amount={data.rutor.ruta12}
baseAmount={data.breakdown.invoices.base6}
/>
<VatRutaRow
ruta="39"
@@ -865,7 +886,7 @@ function VatDeclarationView() {
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa utgående</td>
<td className="py-2 text-right">
{formatAmount(data.rutor.ruta05 + data.rutor.ruta06 + data.rutor.ruta07)} kr
{formatAmount(data.rutor.ruta10 + data.rutor.ruta11 + data.rutor.ruta12)} kr
</td>
</tr>
</tfoot>
@@ -975,7 +996,7 @@ function VatRutaRow({
<td className="py-2 text-right">{noVat ? '-' : `${formatAmount(amount)} kr`}</td>
</tr>
<tr className="text-muted-foreground">
<td className="py-1 pl-6 text-xs">Underlag (ruta {parseInt(ruta) + 5})</td>
<td className="py-1 pl-6 text-xs">Underlag</td>
<td className="py-1 text-right text-xs">{formatAmount(baseAmount)} kr</td>
</tr>
</>
+185
View File
@@ -0,0 +1,185 @@
import type { Metadata } from 'next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import Link from 'next/link'
export const metadata: Metadata = {
title: 'Personuppgiftsbitradesavtal - Gnubok',
}
export default function DPAPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white py-12 px-4">
<div className="max-w-3xl mx-auto space-y-6">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Personuppgiftsbitradesavtal (DPA)
</h1>
<p className="text-muted-foreground">
Enligt GDPR Art. 28 | Senast uppdaterad: 2026-03-05
</p>
</div>
<Card>
<CardHeader>
<CardTitle>1. Roller</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Detta personuppgiftsbitradesavtal (&quot;DPA&quot;) ingar mellan:
</p>
<ul>
<li><strong>Personuppgiftsansvarig (&quot;den Ansvarige&quot;):</strong> Du som anvandare av Gnubok,
i egenskap av ansvarig for de personuppgifter du registrerar i tjansten
(kunder, leverantorer, anstallda m.fl.).</li>
<li><strong>Personuppgiftsbitrade (&quot;Bitradet&quot;):</strong> Arcim, som tillhandahaller
Gnubok-tjansten och behandlar personuppgifter pa dina vagar.</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>2. Behandlingens syfte och omfattning</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Bitradet behandlar personuppgifter for foljande andamal:</p>
<ul>
<li>Tillhandahallande av bokforings- och redovisningstjanster</li>
<li>Lagring och arkivering av bokforingsmaterial</li>
<li>Fakturering och betalningshantering</li>
<li>Bankkontosynkronisering (PSD2)</li>
<li>AI-assisterad kategorisering och kvittohantering (efter separat samtycke)</li>
</ul>
<p>Kategorier av registrerade vars uppgifter behandlas:</p>
<ul>
<li>Den Ansvariges kunder (namn, kontaktuppgifter, organisationsnummer)</li>
<li>Den Ansvariges leverantorer (namn, kontaktuppgifter, bankuppgifter)</li>
<li>Den Ansvarige sjalv (kontouppgifter, foretagsinformation)</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>3. Tekniska och organisatoriska atgarder</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Bitradet vidtar foljande atgarder for att skydda personuppgifterna:</p>
<ul>
<li><strong>Kryptering:</strong> All data krypteras i transit (TLS 1.3) och i vila (AES-256)</li>
<li><strong>Atkomstkontroll:</strong> Row Level Security (RLS) sakerstaller att varje anvandare
enbart kan komma at sina egna uppgifter</li>
<li><strong>Autentisering:</strong> Sakra inloggningsmetoder (magic link, inga losenord lagrade)</li>
<li><strong>Integritetskontroll:</strong> SHA-256 checksummor for alla dokument, med
regelbunden verifiering</li>
<li><strong>Revisionslogg:</strong> Alla andringshandelser loggas automatiskt av databasen
(ej redigerbara)</li>
<li><strong>Oforanderlig bokforing:</strong> Bokforda verifikationer kan inte andras eller
raderas (databasutlosare)</li>
<li><strong>Sakerhetskopior:</strong> Kontinuerliga databaskopior med point-in-time-recovery</li>
<li><strong>EU-lagring:</strong> All primar datalagring sker i EU (eu-central-1)</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>4. Underbitraden</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Bitradet anvander underbitraden for att tillhandahalla tjansten. En fullstandig
forteckning over underbitraden, inklusive syfte och geografisk plats, finns i
var{' '}
<Link href="/privacy" className="text-primary underline underline-offset-4">
integritetspolicy
</Link>.
</p>
<p>
Bitradet kommer att informera den Ansvarige minst 30 dagar i forvag innan
en ny underbitrade anlitas, sa att den Ansvarige har mojlighet att invanda.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>5. Dataintrangsnotifiering</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Vid en personuppgiftsincident ska Bitradet utan ondodigt drojsmal, och senast
inom 72 timmar fran det att incidenten upptacktes, meddela den Ansvarige.
Meddelandet ska innehalla:
</p>
<ul>
<li>Typ av personuppgiftsincident</li>
<li>Kategorier och ungefirligt antal registrerade som berorts</li>
<li>Sannolika konsekvenser av incidenten</li>
<li>Atgarder som vidtagits eller foreslas for att hantera incidenten</li>
</ul>
<p>
Bitradet ska bistå den Ansvarige med den information som behovs for att den
Ansvarige ska kunna uppfylla sin anmalningsplikt till IMY (Integritetsskyddsmyndigheten).
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>6. Revisionsratt</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Den Ansvarige har ratt att, direkt eller genom en oberoende revisor, utfora
revisioner och inspektioner for att sakerst alla att Bitradet uppfyller sina
atagarder enligt detta avtal. Bitradet ska tillhandahalla all nodvandig
information och medverka till revisioner.
</p>
<p>
Revisioner ska ske med rimligt varsel (minst 30 dagar) och under ordinarie
kontorstider. Bitradet kan erbjuda alternativ i form av tredjepartsgranskningar
eller certifieringar.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>7. Radering vid avslut</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Vid uppsagning av tjansten ska Bitradet, enligt den Ansvariges val:
</p>
<ul>
<li>
<strong>Aterlamna:</strong> Exportera alla personuppgifter i maskinlasbart format
(SIE4, JSON, CSV) via tjansens exportfunktioner.
</li>
<li>
<strong>Radera:</strong> Radera alla personuppgifter inom 30 dagar fran
anvandarens begaran, med undantag for uppgifter som maste bevaras enligt lag.
</li>
</ul>
<p>
<strong>Undantag:</strong> Bokforingsmaterial som omfattas av Bokforingslagen (BFL)
7 kap. 2 § (7 ars arkiveringskrav) raderas forst nar lagringsfristen lopt ut.
Under denna period ar materialet skyddat mot obehorig atkomst och andring.
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground text-center">
Detta personuppgiftsbitradesavtal trader i kraft nar du skapar ett konto pa
Gnubok och galler sa lange du anvander tjansten. For fragor, kontakta oss
pa privacy@gnubok.se.
</p>
</CardContent>
</Card>
</div>
</div>
)
}
+237
View File
@@ -0,0 +1,237 @@
import type { Metadata } from 'next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export const metadata: Metadata = {
title: 'Integritetspolicy - Gnubok',
}
export default function PrivacyPolicyPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white py-12 px-4">
<div className="max-w-3xl mx-auto space-y-6">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Integritetspolicy
</h1>
<p className="text-muted-foreground">
Senast uppdaterad: 2026-03-05
</p>
</div>
<Card>
<CardHeader>
<CardTitle>1. Personuppgiftsansvarig</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Arcim (&quot;vi&quot;, &quot;oss&quot;) ar personuppgiftsansvarig for behandlingen av dina
personuppgifter i samband med anvandningen av Gnubok. Vi behandlar dina uppgifter i
enlighet med EU:s dataskyddsforordning (GDPR) och svensk dataskyddslagstiftning.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>2. Vilka uppgifter vi behandlar</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Vi behandlar foljande kategorier av personuppgifter:</p>
<ul>
<li><strong>Kontouppgifter:</strong> E-postadress (for inloggning via magic link)</li>
<li><strong>Foretagsuppgifter:</strong> Foretagsnamn, organisationsnummer, adress, kontaktuppgifter</li>
<li><strong>Bokforingsdata:</strong> Verifikationer, fakturor, kvitton, transaktioner, kontoplaner</li>
<li><strong>Bankdata:</strong> Kontosaldon och transaktioner (via PSD2-koppling)</li>
<li><strong>Dokument:</strong> Uppladdade kvitton, fakturor och andra bokforingsunderlag</li>
<li><strong>Tekniska uppgifter:</strong> IP-adress, enhetstyp, anvandningsstatistik</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>3. Rattslig grund (GDPR Art. 6)</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<ul>
<li>
<strong>Avtal (Art. 6.1b):</strong> Behandling som ar nodvandig for att fullgora vara
tjanster enligt anvandaravtalet.
</li>
<li>
<strong>Rattslig forpliktelse (Art. 6.1c):</strong> Bokforingslagens (BFL) krav pa
7 ars arkivering av raknenskapsmaterial.
</li>
<li>
<strong>Berattigat intresse (Art. 6.1f):</strong> Produktforbattringar, sakerhet och
bedrageriforbud.
</li>
<li>
<strong>Samtycke (Art. 6.1a):</strong> For AI-baserade funktioner som skickar data
till tredjepartstjanster (se separat samtycke vid aktivering).
</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>4. Underbitraden</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Vi anvander foljande underbitraden for att tillhandahalla tjansten. Uppgifterna nedan anger
vilka uppgifter som delas med respektive underbitrade, syftet samt var behandlingen sker
(GDPR Art. 13).
</p>
<div className="overflow-x-auto mt-4">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 pr-4 font-semibold">Underbitrade</th>
<th className="text-left py-2 pr-4 font-semibold">Syfte</th>
<th className="text-left py-2 pr-4 font-semibold">Plats</th>
<th className="text-left py-2 font-semibold">Skyddsmekanism</th>
</tr>
</thead>
<tbody>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Supabase</td>
<td className="py-2 pr-4">Databas, autentisering, fillagring</td>
<td className="py-2 pr-4">EU (eu-central-1)</td>
<td className="py-2">EU-baserad lagring</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Vercel</td>
<td className="py-2 pr-4">Applikationshosting</td>
<td className="py-2 pr-4">Globalt CDN (EU-regioner tillgangliga)</td>
<td className="py-2">EU Data Residency</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Anthropic</td>
<td className="py-2 pr-4">
Kvitto-OCR (receipt-ocr), transaktionskategorisering (ai-categorization),
AI-chattassistent (ai-chat)
</td>
<td className="py-2 pr-4">USA</td>
<td className="py-2">SCCs (standardavtalsklausuler)</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">OpenAI</td>
<td className="py-2 pr-4">
Embedding-generering for likhetssokning (transaktionsmallar, kunskapsbas)
</td>
<td className="py-2 pr-4">USA</td>
<td className="py-2">SCCs (standardavtalsklausuler)</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Enable Banking</td>
<td className="py-2 pr-4">PSD2-bankkontouppkoppling</td>
<td className="py-2 pr-4">EU</td>
<td className="py-2">EU-baserad</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Resend</td>
<td className="py-2 pr-4">Transaktionell e-postleverans</td>
<td className="py-2 pr-4">USA</td>
<td className="py-2">SCCs (standardavtalsklausuler)</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Recapt</td>
<td className="py-2 pr-4">Produktanalys och anvanderfeedback</td>
<td className="py-2 pr-4">EU</td>
<td className="py-2">EU-baserad</td>
</tr>
</tbody>
</table>
</div>
<p className="mt-4 text-sm text-muted-foreground">
AI-funktioner (Anthropic, OpenAI) kraver separat samtycke fore aktivering.
Data skickas forst nar du aktivt godkanner anvandningen.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>5. Tredjelandsoverforing</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Vissa underbitraden ar baserade i USA. For dessa overforing anvands EU-kommissionens
standardavtalsklausuler (SCCs) som skyddsmekanism i enlighet med GDPR kapitel V.
All primaer datalagring (databas, filer) sker inom EU via Supabase (eu-central-1).
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>6. Lagringstid</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<ul>
<li>
<strong>Bokforingsmaterial:</strong> 7 ar fran rakenskapsarets slut, i enlighet
med Bokforingslagen (BFL) 7 kap. 2 §. Systemet hindrar radering av material
kopplat till bokforda verifikationer under denna period.
</li>
<li>
<strong>Kontouppgifter:</strong> Sa lange kontot ar aktivt, plus 30 dagar efter
begaran om radering (for att hantera pagaende bokforingsplikter).
</li>
<li>
<strong>Tekniska loggar:</strong> Maximalt 90 dagar.
</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>7. Dina rattigheter</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Du har foljande rattigheter enligt GDPR:</p>
<ul>
<li><strong>Tillgang (Art. 15):</strong> Du kan begara en kopia av alla dina personuppgifter.</li>
<li><strong>Rattelse (Art. 16):</strong> Du kan begara rattelse av felaktiga uppgifter.</li>
<li><strong>Radering (Art. 17):</strong> Du kan begara radering, med undantag for uppgifter som
omfattas av lagstadgade arkiveringskrav (BFL 7 ar).</li>
<li><strong>Begransning (Art. 18):</strong> Du kan begara begransning av behandlingen.</li>
<li><strong>Dataportabilitet (Art. 20):</strong> Du kan exportera dina uppgifter i
maskinlasbart format (SIE4, JSON, CSV) via exportfunktionerna i appen.</li>
<li><strong>Invandning (Art. 21):</strong> Du kan invanda mot behandling baserad pa
berattigat intresse.</li>
</ul>
<p>
For att utova dina rattigheter, kontakta oss pa adressen nedan. Vi besvarar alla
forfragar inom 30 dagar.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>8. Kontaktuppgifter</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
For fragor om behandlingen av dina personuppgifter, kontakta oss:
</p>
<ul>
<li><strong>Foretag:</strong> Arcim</li>
<li><strong>E-post:</strong> privacy@gnubok.se</li>
</ul>
<p>
Du har aven ratt att lamna klagomal till Integritetsskyddsmyndigheten (IMY),
www.imy.se.
</p>
</CardContent>
</Card>
</div>
</div>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/extensions/ai-consent', () => ({
AI_EXTENSIONS: ['receipt-ocr', 'ai-categorization', 'ai-chat'],
hasAiConsent: vi.fn(),
grantAiConsent: vi.fn(),
revokeAiConsent: vi.fn(),
isAiExtension: vi.fn((id: string) =>
['receipt-ocr', 'ai-categorization', 'ai-chat'].includes(id)
),
}))
import { createClient } from '@/lib/supabase/server'
import { hasAiConsent, grantAiConsent, revokeAiConsent } from '@/lib/extensions/ai-consent'
import { GET, POST, DELETE } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockHasAiConsent = vi.mocked(hasAiConsent)
const mockGrantAiConsent = vi.mocked(grantAiConsent)
const mockRevokeAiConsent = vi.mocked(revokeAiConsent)
function mockAuth(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
}),
},
} as any)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/ai-consent', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const { status } = await parseJsonResponse(await GET())
expect(status).toBe(401)
})
it('returns consent status for all AI extensions', async () => {
mockAuth('user-1')
mockHasAiConsent
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
const { status, body } = await parseJsonResponse<{ data: Record<string, boolean> }>(
await GET()
)
expect(status).toBe(200)
expect(body.data).toEqual({
'receipt-ocr': true,
'ai-categorization': false,
'ai-chat': true,
})
})
})
describe('POST /api/ai-consent', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/ai-consent', {
method: 'POST',
body: { extension_id: 'receipt-ocr' },
})
const { status } = await parseJsonResponse(await POST(req))
expect(status).toBe(401)
})
it('grants consent for valid AI extension', async () => {
mockAuth('user-1')
mockGrantAiConsent.mockResolvedValue(undefined)
const req = createMockRequest('/api/ai-consent', {
method: 'POST',
body: { extension_id: 'receipt-ocr' },
})
const { status, body } = await parseJsonResponse<{ data: { consented: boolean } }>(
await POST(req)
)
expect(status).toBe(200)
expect(body.data.consented).toBe(true)
expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'receipt-ocr')
})
it('returns 400 for non-AI extension', async () => {
mockAuth('user-1')
const req = createMockRequest('/api/ai-consent', {
method: 'POST',
body: { extension_id: 'enable-banking' },
})
const { status } = await parseJsonResponse(await POST(req))
expect(status).toBe(400)
})
})
describe('DELETE /api/ai-consent', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/ai-consent', {
method: 'DELETE',
body: { extension_id: 'ai-chat' },
})
const { status } = await parseJsonResponse(await DELETE(req))
expect(status).toBe(401)
})
it('revokes consent for valid AI extension', async () => {
mockAuth('user-1')
mockRevokeAiConsent.mockResolvedValue(undefined)
const req = createMockRequest('/api/ai-consent', {
method: 'DELETE',
body: { extension_id: 'ai-chat' },
})
const { status, body } = await parseJsonResponse<{ data: { consented: boolean } }>(
await DELETE(req)
)
expect(status).toBe(200)
expect(body.data.consented).toBe(false)
expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'ai-chat')
})
})
+69
View File
@@ -0,0 +1,69 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import {
AI_EXTENSIONS,
hasAiConsent,
grantAiConsent,
revokeAiConsent,
isAiExtension,
} from '@/lib/extensions/ai-consent'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const statuses: Record<string, boolean> = {}
for (const ext of AI_EXTENSIONS) {
statuses[ext] = await hasAiConsent(supabase, user.id, ext)
}
return NextResponse.json({ data: statuses })
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { extension_id } = body
if (!extension_id || !isAiExtension(extension_id)) {
return NextResponse.json(
{ error: 'Invalid or non-AI extension_id' },
{ status: 400 }
)
}
await grantAiConsent(supabase, user.id, extension_id)
return NextResponse.json({ data: { consented: true } })
}
export async function DELETE(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { extension_id } = body
if (!extension_id || !isAiExtension(extension_id)) {
return NextResponse.json(
{ error: 'Invalid or non-AI extension_id' },
{ status: 400 }
)
}
await revokeAiConsent(supabase, user.id, extension_id)
return NextResponse.json({ data: { consented: false } })
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn(),
}))
import { createClient } from '@/lib/supabase/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { GET } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockGetAuditLog = vi.mocked(getAuditLog)
function mockAuth(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
}),
},
} as any)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/audit-trail', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/audit-trail')
const { status, body } = await parseJsonResponse(await GET(req))
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns audit log with data and count', async () => {
mockAuth('user-1')
const entries = [
{ id: '1', action: 'INSERT', table_name: 'journal_entries', created_at: '2024-01-01T00:00:00Z' },
{ id: '2', action: 'COMMIT', table_name: 'journal_entries', created_at: '2024-01-02T00:00:00Z' },
]
mockGetAuditLog.mockResolvedValue({ data: entries as any, count: 2 })
const req = createMockRequest('/api/audit-trail')
const { status, body } = await parseJsonResponse<{ data: any[]; count: number }>(await GET(req))
expect(status).toBe(200)
expect(body.data).toHaveLength(2)
expect(body.count).toBe(2)
expect(mockGetAuditLog).toHaveBeenCalledWith(
expect.anything(),
'user-1',
expect.objectContaining({})
)
})
it('passes query param filters to getAuditLog', async () => {
mockAuth('user-1')
mockGetAuditLog.mockResolvedValue({ data: [], count: 0 })
const req = createMockRequest('/api/audit-trail', {
searchParams: {
action: 'INSERT',
table_name: 'journal_entries',
record_id: 'rec-1',
from_date: '2024-01-01',
to_date: '2024-12-31',
page: '2',
page_size: '25',
},
})
await GET(req)
expect(mockGetAuditLog).toHaveBeenCalledWith(
expect.anything(),
'user-1',
{
action: 'INSERT',
table_name: 'journal_entries',
record_id: 'rec-1',
from_date: '2024-01-01',
to_date: '2024-12-31',
page: 2,
pageSize: 25,
}
)
})
it('returns 500 on service error', async () => {
mockAuth('user-1')
mockGetAuditLog.mockRejectedValue(new Error('DB error'))
const req = createMockRequest('/api/audit-trail')
const { status, body } = await parseJsonResponse(await GET(req))
expect(status).toBe(500)
expect(body).toEqual({ error: 'DB error' })
})
})
+35
View File
@@ -0,0 +1,35 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import type { AuditAction } from '@/types'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const filters = {
action: (searchParams.get('action') as AuditAction) || undefined,
table_name: searchParams.get('table_name') || undefined,
record_id: searchParams.get('record_id') || undefined,
from_date: searchParams.get('from_date') || undefined,
to_date: searchParams.get('to_date') || undefined,
page: searchParams.has('page') ? Number(searchParams.get('page')) : undefined,
pageSize: searchParams.has('page_size') ? Number(searchParams.get('page_size')) : undefined,
}
try {
const result = await getAuditLog(supabase, user.id, filters)
return NextResponse.json({ data: result.data, count: result.count })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to fetch audit log' },
{ status: 500 }
)
}
}
+12
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { extensionRegistry } from '@/lib/extensions/registry'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { isExtensionEnabled } from '@/lib/extensions/toggle-check'
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
import type { ApiRouteDefinition } from '@/lib/extensions/types'
ensureInitialized()
@@ -82,6 +83,17 @@ async function handleRequest(
return NextResponse.json({ error: 'Extension is disabled' }, { status: 403 })
}
// AI consent check
if (isAiExtension(extensionId)) {
const consented = await hasAiConsent(supabase, user.id, extensionId)
if (!consented) {
return NextResponse.json(
{ error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' },
{ status: 403 }
)
}
}
// Find matching route (supports :param patterns)
let matchedRoute: ApiRouteDefinition | null = null
let extractedParams: Record<string, string> = {}
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest } from '@/tests/helpers'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn(),
}))
import { createClient } from '@/lib/supabase/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { GET } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockGetAuditLog = vi.mocked(getAuditLog)
function mockAuth(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
}),
},
} as any)
}
const sampleEntries = [
{
id: '1',
user_id: 'user-1',
action: 'INSERT' as const,
table_name: 'journal_entries',
record_id: 'rec-1',
actor_id: null,
old_state: null,
new_state: { description: 'Test entry' },
description: 'Created journal entry',
created_at: '2024-06-15T10:00:00Z',
},
{
id: '2',
user_id: 'user-1',
action: 'COMMIT' as const,
table_name: 'journal_entries',
record_id: 'rec-1',
actor_id: null,
old_state: { status: 'draft' },
new_state: { status: 'posted' },
description: 'Committed journal entry',
created_at: '2024-06-15T10:01:00Z',
},
]
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/reports/audit-trail', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/reports/audit-trail')
const res = await GET(req)
expect(res.status).toBe(401)
})
it('returns CSV format with correct headers', async () => {
mockAuth('user-1')
mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 })
const req = createMockRequest('/api/reports/audit-trail', {
searchParams: { format: 'csv' },
})
const res = await GET(req)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('text/csv; charset=utf-8')
expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="audit-trail.csv"')
const text = await res.text()
const lines = text.split('\n')
expect(lines[0]).toBe('timestamp,action,table_name,record_id,description,old_state,new_state')
expect(lines).toHaveLength(3) // header + 2 entries
expect(lines[1]).toContain('INSERT')
expect(lines[1]).toContain('journal_entries')
})
it('returns JSON format as downloadable file', async () => {
mockAuth('user-1')
mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 })
const req = createMockRequest('/api/reports/audit-trail', {
searchParams: { format: 'json' },
})
const res = await GET(req)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/json')
expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="audit-trail.json"')
const body = await res.json()
expect(body.data).toHaveLength(2)
expect(body.count).toBe(2)
})
it('paginates through all entries', async () => {
mockAuth('user-1')
// First call returns 500 entries (full page), second returns 100 (last page)
const bigPage = Array.from({ length: 500 }, (_, i) => ({
...sampleEntries[0],
id: `entry-${i}`,
}))
const lastPage = Array.from({ length: 100 }, (_, i) => ({
...sampleEntries[0],
id: `entry-${500 + i}`,
}))
mockGetAuditLog
.mockResolvedValueOnce({ data: bigPage as any, count: 600 })
.mockResolvedValueOnce({ data: lastPage as any, count: 600 })
const req = createMockRequest('/api/reports/audit-trail', {
searchParams: { format: 'json' },
})
const res = await GET(req)
const body = await res.json()
expect(body.data).toHaveLength(600)
expect(mockGetAuditLog).toHaveBeenCalledTimes(2)
})
})
+94
View File
@@ -0,0 +1,94 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import type { AuditLogEntry, AuditAction } from '@/types'
const CSV_HEADERS = 'timestamp,action,table_name,record_id,description,old_state,new_state'
function escapeCSV(value: string | null | undefined): string {
if (value == null) return ''
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
function entryToCSVRow(entry: AuditLogEntry): string {
return [
escapeCSV(entry.created_at),
escapeCSV(entry.action),
escapeCSV(entry.table_name),
escapeCSV(entry.record_id),
escapeCSV(entry.description),
escapeCSV(entry.old_state ? JSON.stringify(entry.old_state) : null),
escapeCSV(entry.new_state ? JSON.stringify(entry.new_state) : null),
].join(',')
}
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const format = searchParams.get('format') || 'json'
const filters = {
action: (searchParams.get('action') as AuditAction) || undefined,
table_name: searchParams.get('table_name') || undefined,
record_id: searchParams.get('record_id') || undefined,
from_date: searchParams.get('from_date') || undefined,
to_date: searchParams.get('to_date') || undefined,
}
try {
// Paginate through all matching entries
const allEntries: AuditLogEntry[] = []
let page = 1
const pageSize = 500
while (true) {
const result = await getAuditLog(supabase, user.id, {
...filters,
page,
pageSize,
})
allEntries.push(...result.data)
if (allEntries.length >= result.count || result.data.length < pageSize) {
break
}
page++
}
if (format === 'csv') {
const csvRows = [CSV_HEADERS, ...allEntries.map(entryToCSVRow)]
const csvContent = csvRows.join('\n')
return new NextResponse(csvContent, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="audit-trail.csv"',
},
})
}
// Default: JSON
return new NextResponse(JSON.stringify({ data: allEntries, count: allEntries.length }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Content-Disposition': 'attachment; filename="audit-trail.json"',
},
})
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate audit trail report' },
{ status: 500 }
)
}
}
+38
View File
@@ -0,0 +1,38 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateFullArchive } from '@/lib/reports/full-archive-export'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
if (!periodId) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
try {
const zipBuffer = await generateFullArchive(supabase, user.id, {
period_id: periodId,
include_documents: searchParams.get('include_documents') !== 'false',
})
return new NextResponse(zipBuffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="arkiv_${periodId}.zip"`,
},
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to generate archive'
const status = message.includes('not found') ? 404 : 500
return NextResponse.json({ error: message }, { status })
}
}
+63
View File
@@ -0,0 +1,63 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateINK2Declaration } from '@/lib/reports/ink2/ink2-engine'
import {
generateSRUFile,
sruFileToString,
getSRUFilename,
} from '@/lib/reports/ink2/sru-generator'
/**
* GET /api/reports/ink2
*
* Generate INK2 declaration for aktiebolag.
*
* Query parameters:
* - period_id: Fiscal period ID (required)
* - format: 'json' (default) or 'sru' for SRU file download
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const format = searchParams.get('format') || 'json'
if (!periodId) {
return NextResponse.json(
{ error: 'period_id is required' },
{ status: 400 }
)
}
try {
const declaration = await generateINK2Declaration(supabase, user.id, periodId)
if (format === 'sru') {
const sruFile = generateSRUFile(declaration)
const sruContent = sruFileToString(sruFile)
const filename = getSRUFilename(declaration)
return new NextResponse(sruContent, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
}
return NextResponse.json({ data: declaration })
} catch (err) {
console.error('Error generating INK2 declaration:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate INK2 declaration' },
{ status: 500 }
)
}
}
+102
View File
@@ -0,0 +1,102 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AI_DATA_DISCLOSURES, type AiExtensionId } from '@/lib/extensions/ai-consent'
import Link from 'next/link'
interface AiConsentDialogProps {
extensionId: AiExtensionId
open: boolean
onOpenChange: (open: boolean) => void
onConsented: () => void
}
export function AiConsentDialog({
extensionId,
open,
onOpenChange,
onConsented,
}: AiConsentDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const disclosure = AI_DATA_DISCLOSURES[extensionId]
async function handleAccept() {
setIsSubmitting(true)
try {
const res = await fetch('/api/ai-consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ extension_id: extensionId }),
})
if (res.ok) {
onOpenChange(false)
onConsented()
}
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>AI-samtycke kravs</DialogTitle>
<DialogDescription>
Denna funktion anvander AI-tjanster fran externa leverantorer.
Granska informationen nedan innan du fortsatter.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<p className="text-sm font-medium mb-1">Leverantor</p>
<p className="text-sm text-muted-foreground">{disclosure.provider}</p>
</div>
<div>
<p className="text-sm font-medium mb-1">Data som skickas</p>
<ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
{disclosure.dataTypes.map((dt) => (
<li key={dt}>{dt}</li>
))}
</ul>
</div>
<div>
<p className="text-sm font-medium mb-1">Syfte</p>
<p className="text-sm text-muted-foreground">{disclosure.purpose}</p>
</div>
<p className="text-xs text-muted-foreground">
Las mer i var{' '}
<Link href="/privacy" className="underline underline-offset-4" target="_blank">
integritetspolicy
</Link>
. Du kan nar som helst aterkalla ditt samtycke i installningarna.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={handleAccept} disabled={isSubmitting}>
{isSubmitting ? 'Sparar...' : 'Jag samtycker'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -584,8 +584,8 @@ export default function Step3TaxRegistration({
content={
<div className="space-y-2">
<p className="font-medium">Behöver jag momsregistrera mig?</p>
<p>Ja, om din omsättning överstiger 80 000 kr per år. Med moms lägger du 25% extra dina fakturor, men får också dra av moms dina inköp.</p>
<p className="text-xs text-muted-foreground">Om din omsättning överstiger 80 000 kr per år behöver du momsregistrera dig.</p>
<p>Ja, om din omsättning överstiger 120 000 kr per år. Med moms lägger du 25% extra dina fakturor, men får också dra av moms dina inköp.</p>
<p className="text-xs text-muted-foreground">Om din omsättning överstiger 120 000 kr per år behöver du momsregistrera dig.</p>
</div>
}
side="right"
@@ -611,7 +611,7 @@ export default function Step3TaxRegistration({
Jag är momsregistrerad
</Label>
<p className="text-sm text-muted-foreground">
Obligatoriskt om din omsättning överstiger 80 000 kr per år.
Obligatoriskt om din omsättning överstiger 120 000 kr per år.
</p>
</div>
</div>
+293
View File
@@ -0,0 +1,293 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle, Info } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import { formatCurrency } from '@/lib/utils'
import type { INK2Declaration, INK2SRUCode } from '@/lib/reports/ink2/types'
import {
INK2_RUTA_LABELS,
INK2_ASSET_CODES,
INK2_EQUITY_LIABILITY_CODES,
INK2_INCOME_STATEMENT_CODES,
} from '@/lib/reports/ink2/types'
export function INK2DeclarationView({ periodId }: { periodId: string }) {
const [data, setData] = useState<INK2Declaration | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchDeclaration = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/ink2?period_id=${periodId}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta INK2-deklaration')
} finally {
setLoading(false)
}
}
const downloadSRU = () => {
window.open(`/api/reports/ink2?period_id=${periodId}&format=sru`, '_blank')
}
return (
<div className="space-y-4">
{/* Info card */}
<Card>
<CardHeader>
<CardTitle className="text-lg">INK2 (Aktiebolag)</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-2 mb-4 p-3 bg-blue-50 rounded-md">
<Info className="h-4 w-4 text-blue-600 mt-0.5 shrink-0" />
<p className="text-sm text-blue-800">
INK2 visar det bokföringsmässiga resultatet baserat din bokföring.
Skattemässiga justeringar (ej avdragsgilla kostnader, periodiseringsfonder m.m.)
hanteras av din revisor/redovisningskonsult.
</p>
</div>
<div className="flex gap-2">
<Button onClick={fetchDeclaration} disabled={loading}>
{loading ? 'Laddar...' : 'Hämta INK2'}
</Button>
{data && (
<Button variant="outline" onClick={downloadSRU}>
<Download className="h-4 w-4 mr-2" />
Ladda ner SRU-fil
</Button>
)}
</div>
</CardContent>
</Card>
{error && (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)}
{data && (
<>
{/* Warnings */}
{data.warnings.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
{data.warnings.map((warning, i) => (
<p key={i} className="text-sm text-orange-800">{warning}</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Company info */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>
{data.companyInfo.companyName}
</CardTitle>
<Badge className="bg-blue-100 text-blue-800">
{data.fiscalYear.name}
</Badge>
</div>
{data.companyInfo.orgNumber && (
<p className="text-sm text-muted-foreground">
Org.nr: {data.companyInfo.orgNumber}
</p>
)}
</CardHeader>
</Card>
{/* Assets section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Tillgångar</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{INK2_ASSET_CODES.map((code) => (
<INK2DeclarationRow
key={code}
code={code}
label={INK2_RUTA_LABELS[code]}
amount={data.rutor[code]}
accounts={data.breakdown[code]?.accounts || []}
/>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa tillgångar</td>
<td className="py-2 text-right">
{formatCurrency(data.totals.totalAssets)}
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Equity & Liabilities section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Eget kapital och skulder</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{INK2_EQUITY_LIABILITY_CODES.map((code) => (
<INK2DeclarationRow
key={code}
code={code}
label={INK2_RUTA_LABELS[code]}
amount={data.rutor[code]}
accounts={data.breakdown[code]?.accounts || []}
/>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa eget kapital och skulder</td>
<td className="py-2 text-right">
{formatCurrency(data.totals.totalEquityLiabilities)}
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Income Statement section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Resultaträkning</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{INK2_INCOME_STATEMENT_CODES.map((code) => {
const isExpense = code !== '7310' && code !== '7370' && code !== '7380'
return (
<INK2DeclarationRow
key={code}
code={code}
label={INK2_RUTA_LABELS[code]}
amount={data.rutor[code]}
accounts={data.breakdown[code]?.accounts || []}
isExpense={isExpense}
/>
)
})}
</tbody>
<tfoot>
<tr className="border-t font-medium">
<td className="py-2">Rörelseresultat</td>
<td className={`py-2 text-right ${data.totals.operatingResult >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(data.totals.operatingResult)}
</td>
</tr>
<tr className="border-t-2 font-semibold">
<td className="py-2">Resultat efter finansiella poster</td>
<td className={`py-2 text-right ${data.totals.resultAfterFinancial >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(data.totals.resultAfterFinancial)}
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
</>
)}
{!data && !loading && !error && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Klicka &quot;Hämta INK2&quot; för att generera deklarationsunderlaget.
</CardContent>
</Card>
)}
</div>
)
}
function INK2DeclarationRow({
code,
label,
amount,
accounts,
isExpense,
}: {
code: INK2SRUCode
label: string
amount: number
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
isExpense?: boolean
}) {
const [expanded, setExpanded] = useState(false)
if (amount === 0 && accounts.length === 0) return null
return (
<>
<tr
className="border-b cursor-pointer hover:bg-muted/50"
onClick={() => accounts.length > 0 && setExpanded(!expanded)}
>
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{code}</span>
{label}
{accounts.length > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({accounts.length} konton)
</span>
)}
</td>
<td className="py-2 text-right">
{isExpense && amount > 0 ? '-' : ''}{formatCurrency(Math.abs(amount))}
</td>
</tr>
{expanded && accounts.length > 0 && (
<tr>
<td colSpan={2} className="py-2 pl-8 bg-muted/30">
<table className="w-full text-xs">
<tbody>
{accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1">
<AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" />
</td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">
{isExpense && acc.amount > 0 ? '-' : ''}{formatCurrency(Math.abs(acc.amount))}
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
+3 -3
View File
@@ -20,9 +20,9 @@ const COLORS = [
export function VatCompositionChart({ rutor }: VatCompositionChartProps) {
const chartData = useMemo(() => {
const segments = [
{ name: 'Utgående 25%', value: rutor.ruta05 },
{ name: 'Utgående 12%', value: rutor.ruta06 },
{ name: 'Utgående 6%', value: rutor.ruta07 },
{ name: 'Utgående 25%', value: rutor.ruta10 },
{ name: 'Utgående 12%', value: rutor.ruta11 },
{ name: 'Utgående 6%', value: rutor.ruta12 },
{ name: 'Ingående moms', value: rutor.ruta48 },
]
return segments.filter((s) => s.value > 0)
+7 -6
View File
@@ -423,16 +423,17 @@ export function createAccountingTools(supabase: SupabaseClient, userId: string)
return JSON.stringify({
period: `${period.start} ${period.end}`,
output_vat_25: declaration.rutor.ruta05,
output_vat_12: declaration.rutor.ruta06,
output_vat_6: declaration.rutor.ruta07,
output_vat_25: declaration.rutor.ruta10,
output_vat_12: declaration.rutor.ruta11,
output_vat_6: declaration.rutor.ruta12,
total_output_vat: summary.totalOutputVat,
input_vat: summary.totalInputVat,
vat_to_pay: summary.vatToPay,
is_refund: summary.isRefund,
revenue_basis_25: declaration.rutor.ruta10,
revenue_basis_12: declaration.rutor.ruta11,
revenue_basis_6: declaration.rutor.ruta12,
domestic_taxable_sales: declaration.rutor.ruta05,
revenue_basis_25: declaration.breakdown.invoices.base25,
revenue_basis_12: declaration.breakdown.invoices.base12,
revenue_basis_6: declaration.breakdown.invoices.base6,
invoice_count: declaration.invoiceCount,
transaction_count: declaration.transactionCount,
})
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockSupabase } from '@/tests/helpers'
import {
hasAiConsent,
grantAiConsent,
revokeAiConsent,
isAiExtension,
CURRENT_CONSENT_VERSION,
} from '../ai-consent'
describe('ai-consent', () => {
let supabase: ReturnType<typeof createMockSupabase>['supabase']
let mockResult: ReturnType<typeof createMockSupabase>['mockResult']
beforeEach(() => {
vi.clearAllMocks()
const mock = createMockSupabase()
supabase = mock.supabase
mockResult = mock.mockResult
})
describe('isAiExtension', () => {
it('returns true for AI extensions', () => {
expect(isAiExtension('receipt-ocr')).toBe(true)
expect(isAiExtension('ai-categorization')).toBe(true)
expect(isAiExtension('ai-chat')).toBe(true)
})
it('returns false for non-AI extensions', () => {
expect(isAiExtension('enable-banking')).toBe(false)
expect(isAiExtension('email')).toBe(false)
expect(isAiExtension('calendar')).toBe(false)
})
})
describe('hasAiConsent', () => {
it('returns true for non-AI extensions without checking DB', async () => {
const result = await hasAiConsent(supabase as any, 'user-1', 'enable-banking')
expect(result).toBe(true)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns false when no consent record exists', async () => {
mockResult({ data: null })
const result = await hasAiConsent(supabase as any, 'user-1', 'receipt-ocr')
expect(result).toBe(false)
})
it('returns true after consent is granted with current version', async () => {
mockResult({
data: {
value: {
consented: true,
version: CURRENT_CONSENT_VERSION,
granted_at: '2024-01-01T00:00:00Z',
},
},
})
const result = await hasAiConsent(supabase as any, 'user-1', 'receipt-ocr')
expect(result).toBe(true)
})
it('returns false after consent is revoked', async () => {
mockResult({
data: {
value: {
consented: false,
version: CURRENT_CONSENT_VERSION,
revoked_at: '2024-01-02T00:00:00Z',
},
},
})
const result = await hasAiConsent(supabase as any, 'user-1', 'receipt-ocr')
expect(result).toBe(false)
})
it('returns false for outdated consent version', async () => {
mockResult({
data: {
value: {
consented: true,
version: CURRENT_CONSENT_VERSION - 1,
granted_at: '2024-01-01T00:00:00Z',
},
},
})
const result = await hasAiConsent(supabase as any, 'user-1', 'receipt-ocr')
expect(result).toBe(false)
})
})
describe('grantAiConsent', () => {
it('upserts consent record to extension_data', async () => {
mockResult({ data: null, error: null })
await grantAiConsent(supabase as any, 'user-1', 'receipt-ocr')
expect(supabase.from).toHaveBeenCalledWith('extension_data')
})
it('does nothing for non-AI extensions', async () => {
await grantAiConsent(supabase as any, 'user-1', 'enable-banking')
expect(supabase.from).not.toHaveBeenCalled()
})
})
describe('revokeAiConsent', () => {
it('upserts revoked consent to extension_data', async () => {
mockResult({ data: null, error: null })
await revokeAiConsent(supabase as any, 'user-1', 'ai-chat')
expect(supabase.from).toHaveBeenCalledWith('extension_data')
})
it('does nothing for non-AI extensions', async () => {
await revokeAiConsent(supabase as any, 'user-1', 'email')
expect(supabase.from).not.toHaveBeenCalled()
})
})
})
+130
View File
@@ -0,0 +1,130 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* AI Consent Service
*
* Manages per-extension consent for AI features that send user data to
* third-party AI providers. Required before any AI extension API call.
*
* Uses the existing `extension_data` table (migration 020) with key='ai_consent'.
*
* Version bump policy:
* - BUMP version when: New sub-processor added, new data type sent to existing
* provider, changed processing purpose.
* - DO NOT bump when: Bug fix, model upgrade within same provider
* (e.g. Haiku 4.5 -> Haiku 5), performance improvements.
* - When version bumps, existing consents become invalid and users must re-consent.
*/
export const CURRENT_CONSENT_VERSION = 1
export const AI_EXTENSIONS = ['receipt-ocr', 'ai-categorization', 'ai-chat'] as const
export type AiExtensionId = (typeof AI_EXTENSIONS)[number]
export function isAiExtension(extensionId: string): extensionId is AiExtensionId {
return (AI_EXTENSIONS as readonly string[]).includes(extensionId)
}
export const AI_DATA_DISCLOSURES: Record<AiExtensionId, {
provider: string
dataTypes: string[]
purpose: string
}> = {
'receipt-ocr': {
provider: 'Anthropic',
dataTypes: ['Kvittobilder', 'Extraherad text fran kvitton'],
purpose: 'Automatisk avlasning och kategorisering av kvitton',
},
'ai-categorization': {
provider: 'Anthropic, OpenAI',
dataTypes: ['Transaktionsbeskrivningar', 'Belopp', 'Bokformallar'],
purpose: 'Automatisk kategorisering av banktransaktioner',
},
'ai-chat': {
provider: 'Anthropic, OpenAI',
dataTypes: ['Chattmeddelanden', 'Bokforingsdata som refereras i chatten'],
purpose: 'AI-assistent for bokforingsfragor',
},
}
/**
* Check if user has valid AI consent for the given extension.
* Returns true for non-AI extensions (no consent needed).
*/
export async function hasAiConsent(
supabase: SupabaseClient,
userId: string,
extensionId: string
): Promise<boolean> {
if (!isAiExtension(extensionId)) {
return true
}
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', userId)
.eq('extension_id', extensionId)
.eq('key', 'ai_consent')
.single()
if (!data?.value) return false
const consent = data.value as { consented: boolean; version: number }
return consent.consented === true && consent.version >= CURRENT_CONSENT_VERSION
}
/**
* Grant AI consent for an extension.
*/
export async function grantAiConsent(
supabase: SupabaseClient,
userId: string,
extensionId: string
): Promise<void> {
if (!isAiExtension(extensionId)) return
await supabase
.from('extension_data')
.upsert(
{
user_id: userId,
extension_id: extensionId,
key: 'ai_consent',
value: {
consented: true,
version: CURRENT_CONSENT_VERSION,
granted_at: new Date().toISOString(),
},
},
{ onConflict: 'user_id,extension_id,key' }
)
}
/**
* Revoke AI consent for an extension.
*/
export async function revokeAiConsent(
supabase: SupabaseClient,
userId: string,
extensionId: string
): Promise<void> {
if (!isAiExtension(extensionId)) return
await supabase
.from('extension_data')
.upsert(
{
user_id: userId,
extension_id: extensionId,
key: 'ai_consent',
value: {
consented: false,
version: CURRENT_CONSENT_VERSION,
revoked_at: new Date().toISOString(),
},
},
{ onConflict: 'user_id,extension_id,key' }
)
}
@@ -0,0 +1,236 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import JSZip from 'jszip'
import { generateFullArchive } from '../full-archive-export'
import { createQueuedMockSupabase } from '@/tests/helpers'
vi.mock('../sie-export', () => ({
generateSIEExport: vi.fn().mockResolvedValue('#FLAGGA 0\n#PROGRAM "ERPBase"'),
}))
vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn().mockResolvedValue({
rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true,
}),
}))
vi.mock('../income-statement', () => ({
generateIncomeStatement: vi.fn().mockResolvedValue({
sections: [], netResult: 0, period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../balance-sheet', () => ({
generateBalanceSheet: vi.fn().mockResolvedValue({
asset_sections: [], equity_liability_sections: [],
total_assets: 0, total_equity_liabilities: 0,
period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../general-ledger', () => ({
generateGeneralLedger: vi.fn().mockResolvedValue({
accounts: [], period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../journal-register', () => ({
generateJournalRegister: vi.fn().mockResolvedValue({
entries: [], total_entries: 0, total_debit: 0, total_credit: 0,
period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../vat-declaration', () => ({
calculateVatDeclaration: vi.fn().mockResolvedValue({
period: { type: 'yearly', year: 2024, period: 1, start: '2024-01-01', end: '2024-12-31' },
rutor: {
ruta05: 0, ruta06: 0, ruta07: 0,
ruta10: 0, ruta11: 0, ruta12: 0,
ruta39: 0, ruta40: 0, ruta48: 0, ruta49: 0,
},
invoiceCount: 0, transactionCount: 0,
breakdown: {
invoices: { ruta05: 0, ruta06: 0, ruta07: 0, ruta10: 0, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0, base25: 0, base12: 0, base6: 0 },
transactions: { ruta48: 0 },
receipts: { ruta48: 0 },
},
}),
}))
vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn().mockResolvedValue({ data: [], count: 0 }),
}))
describe('generateFullArchive', () => {
let supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']
let enqueueMany: ReturnType<typeof createQueuedMockSupabase>['enqueueMany']
beforeEach(() => {
vi.clearAllMocks()
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
})
function enqueueStandardResponses(opts?: { includeDocuments?: boolean }) {
// 1. fiscal_periods query
enqueueMany([
{
data: {
id: 'period-1',
period_start: '2024-01-01',
period_end: '2024-12-31',
user_id: 'user-1',
},
},
// 2. company_settings query
{
data: {
company_name: 'Test AB',
org_number: '5566778899',
moms_period: 'quarterly',
},
},
])
if (opts?.includeDocuments !== false) {
enqueueMany([
// 3. document_attachments query
{ data: [] },
])
}
}
it('generates a ZIP with expected file structure', async () => {
enqueueStandardResponses()
const buffer = await generateFullArchive(supabase as any, 'user-1', {
period_id: 'period-1',
})
const zip = await JSZip.loadAsync(buffer)
expect(zip.file('bokforing.se')).not.toBeNull()
expect(zip.file('rapporter/saldobalans.json')).not.toBeNull()
expect(zip.file('rapporter/resultatrakning.json')).not.toBeNull()
expect(zip.file('rapporter/balansrakning.json')).not.toBeNull()
expect(zip.file('rapporter/huvudbok.json')).not.toBeNull()
expect(zip.file('rapporter/grundbok.json')).not.toBeNull()
expect(zip.file('rapporter/momsdeklaration.json')).not.toBeNull()
expect(zip.file('dokument/manifest.json')).not.toBeNull()
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
})
it('handles missing documents gracefully', async () => {
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
enqueueMany([
// fiscal_periods
{
data: {
id: 'period-1',
period_start: '2024-01-01',
period_end: '2024-12-31',
user_id: 'user-1',
},
},
// company_settings
{
data: {
company_name: 'Test AB',
org_number: '5566778899',
moms_period: 'quarterly',
},
},
// document_attachments — one document
{
data: [
{
id: 'doc-1',
file_name: 'receipt.pdf',
storage_path: 'documents/user-1/receipt.pdf',
journal_entry_id: 'entry-1',
},
],
},
// journal_entries in period
{
data: [{ id: 'entry-1' }],
},
])
// Mock storage download to fail
supabase.storage.from = vi.fn().mockReturnValue({
download: vi.fn().mockResolvedValue({
data: null,
error: { message: 'File not found' },
}),
})
const buffer = await generateFullArchive(supabase as any, 'user-1', {
period_id: 'period-1',
})
const zip = await JSZip.loadAsync(buffer)
const manifestFile = zip.file('dokument/manifest.json')
expect(manifestFile).not.toBeNull()
const manifest = JSON.parse(await manifestFile!.async('text'))
expect(manifest).toHaveLength(1)
expect(manifest[0].status).toBe('error')
expect(manifest[0].error).toBe('File not found')
})
it('skips documents when include_documents is false', async () => {
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
enqueueMany([
// fiscal_periods
{
data: {
id: 'period-1',
period_start: '2024-01-01',
period_end: '2024-12-31',
user_id: 'user-1',
},
},
// company_settings
{
data: {
company_name: 'Test AB',
org_number: '5566778899',
moms_period: 'quarterly',
},
},
])
const buffer = await generateFullArchive(supabase as any, 'user-1', {
period_id: 'period-1',
include_documents: false,
})
const zip = await JSZip.loadAsync(buffer)
// Should not have dokument folder
expect(zip.file('dokument/manifest.json')).toBeNull()
// Should still have other files
expect(zip.file('bokforing.se')).not.toBeNull()
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
})
it('throws when fiscal period not found', async () => {
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
enqueueMany([{ data: null }])
await expect(
generateFullArchive(supabase as any, 'user-1', { period_id: 'nonexistent' })
).rejects.toThrow('Fiscal period not found')
})
})
+33 -27
View File
@@ -95,21 +95,21 @@ describe('getVatDeclarationSummary', () => {
const declaration: VatDeclaration = {
period: { type: 'monthly', year: 2024, period: 1, start: '2024-01-01', end: '2024-01-31' },
rutor: {
ruta05: 2500,
ruta05: 10000, // domestic taxable sales
ruta06: 0,
ruta07: 0,
ruta10: 10000,
ruta10: 2500, // output VAT 25%
ruta11: 0,
ruta12: 0,
ruta39: 0,
ruta40: 0,
ruta48: 1000,
ruta49: 1500,
ruta49: 1500, // 2500 - 1000
},
invoiceCount: 5,
transactionCount: 10,
breakdown: {
invoices: { ruta05: 2500, ruta06: 0, ruta07: 0, ruta10: 10000, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0 },
invoices: { ruta05: 10000, ruta06: 0, ruta07: 0, ruta10: 2500, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0, base25: 10000, base12: 0, base6: 0 },
transactions: { ruta48: 1000 },
receipts: { ruta48: 0 },
},
@@ -126,21 +126,21 @@ describe('getVatDeclarationSummary', () => {
const declaration: VatDeclaration = {
period: { type: 'monthly', year: 2024, period: 1, start: '2024-01-01', end: '2024-01-31' },
rutor: {
ruta05: 500,
ruta05: 2000, // domestic taxable sales
ruta06: 0,
ruta07: 0,
ruta10: 2000,
ruta10: 500, // output VAT 25%
ruta11: 0,
ruta12: 0,
ruta39: 0,
ruta40: 0,
ruta48: 3000,
ruta49: -2500,
ruta49: -2500, // 500 - 3000
},
invoiceCount: 1,
transactionCount: 20,
breakdown: {
invoices: { ruta05: 500, ruta06: 0, ruta07: 0, ruta10: 2000, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0 },
invoices: { ruta05: 2000, ruta06: 0, ruta07: 0, ruta10: 500, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0, base25: 2000, base12: 0, base6: 0 },
transactions: { ruta48: 3000 },
receipts: { ruta48: 0 },
},
@@ -170,15 +170,16 @@ describe('calculateVatDeclaration', () => {
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
expect(result.rutor.ruta06).toBe(0)
expect(result.rutor.ruta07).toBe(0)
expect(result.rutor.ruta10).toBe(0)
expect(result.rutor.ruta11).toBe(0)
expect(result.rutor.ruta12).toBe(0)
expect(result.rutor.ruta48).toBe(0)
expect(result.rutor.ruta49).toBe(0)
expect(result.invoiceCount).toBe(0)
expect(result.transactionCount).toBe(0)
})
it('sums output VAT from 2611/2621/2631 credit balances', async () => {
it('sums output VAT to ruta10/11/12 and revenue to ruta05', async () => {
results = [
{
data: [
@@ -196,12 +197,16 @@ describe('calculateVatDeclaration', () => {
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta06).toBe(600)
expect(result.rutor.ruta07).toBe(180)
expect(result.rutor.ruta10).toBe(10000)
expect(result.rutor.ruta11).toBe(5000)
expect(result.rutor.ruta12).toBe(3000)
// Output VAT in ruta 10/11/12
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta11).toBe(600)
expect(result.rutor.ruta12).toBe(180)
// All domestic revenue combined in ruta 05
expect(result.rutor.ruta05).toBe(18000)
// Per-rate base amounts in breakdown
expect(result.breakdown.invoices.base25).toBe(10000)
expect(result.breakdown.invoices.base12).toBe(5000)
expect(result.breakdown.invoices.base6).toBe(3000)
expect(result.invoiceCount).toBe(2)
})
@@ -277,9 +282,9 @@ describe('calculateVatDeclaration', () => {
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
// Net: 2500 - 625 = 1875 output VAT, 10000 - 2500 = 7500 revenue
expect(result.rutor.ruta05).toBe(1875)
expect(result.rutor.ruta10).toBe(7500)
// Net: 2500 - 625 = 1875 output VAT in ruta10, 10000 - 2500 = 7500 revenue in ruta05
expect(result.rutor.ruta10).toBe(1875)
expect(result.rutor.ruta05).toBe(7500)
expect(result.invoiceCount).toBe(2)
})
@@ -298,7 +303,8 @@ describe('calculateVatDeclaration', () => {
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta05).toBe(10000)
expect(result.rutor.ruta48).toBe(350)
expect(result.rutor.ruta49).toBe(2150) // 2500 - 350
})
@@ -354,12 +360,12 @@ describe('calculateVatDeclaration', () => {
const result = await calculateVatDeclaration(supabase, 'user-1', 'quarterly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta06).toBe(600)
expect(result.rutor.ruta07).toBe(180)
expect(result.rutor.ruta10).toBe(10000)
expect(result.rutor.ruta11).toBe(5000)
expect(result.rutor.ruta12).toBe(3000)
// Output VAT in ruta 10/11/12
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta11).toBe(600)
expect(result.rutor.ruta12).toBe(180)
// All domestic revenue combined in ruta 05
expect(result.rutor.ruta05).toBe(18000)
expect(result.rutor.ruta48).toBe(1000)
// Output: 2500 + 600 + 180 = 3280, Input: 1000 → Pay: 2280
expect(result.rutor.ruta49).toBe(2280)
+191
View File
@@ -0,0 +1,191 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import JSZip from 'jszip'
import { generateSIEExport } from './sie-export'
import { generateTrialBalance } from './trial-balance'
import { generateIncomeStatement } from './income-statement'
import { generateBalanceSheet } from './balance-sheet'
import { generateGeneralLedger } from './general-ledger'
import { generateJournalRegister } from './journal-register'
import { calculateVatDeclaration } from './vat-declaration'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import type { AuditLogEntry } from '@/types'
export interface FullArchiveOptions {
period_id: string
include_documents?: boolean
}
interface DocumentManifestEntry {
file_name: string
storage_path: string
status: 'downloaded' | 'missing' | 'error'
error?: string
}
/**
* Generate a full archive ZIP for a fiscal period.
*
* Contains SIE4 file, all financial reports, attached documents, and audit trail.
* This fulfills the Swedish accounting law (BFL) requirement for complete archives.
*/
export async function generateFullArchive(
supabase: SupabaseClient,
userId: string,
options: FullArchiveOptions
): Promise<ArrayBuffer> {
const { period_id, include_documents = true } = options
// Fetch fiscal period
const { data: period } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', period_id)
.eq('user_id', userId)
.single()
if (!period) {
throw new Error('Fiscal period not found')
}
// Fetch company settings
const { data: company } = await supabase
.from('company_settings')
.select('company_name, org_number, moms_period')
.eq('user_id', userId)
.single()
if (!company) {
throw new Error('Company settings not found')
}
const zip = new JSZip()
// 1. SIE4 export
const sieContent = await generateSIEExport(supabase, userId, {
fiscal_period_id: period_id,
company_name: company.company_name || 'Unknown',
org_number: company.org_number,
program_name: 'ERPBase',
})
zip.file('bokforing.se', sieContent)
// 2. Reports folder
const rapporter = zip.folder('rapporter')!
const [trialBalance, incomeStatement, balanceSheet, generalLedger, journalRegister] =
await Promise.all([
generateTrialBalance(supabase, userId, period_id),
generateIncomeStatement(supabase, userId, period_id),
generateBalanceSheet(supabase, userId, period_id),
generateGeneralLedger(supabase, userId, period_id),
generateJournalRegister(supabase, userId, period_id),
])
rapporter.file('saldobalans.json', JSON.stringify(trialBalance, null, 2))
rapporter.file('resultatrakning.json', JSON.stringify(incomeStatement, null, 2))
rapporter.file('balansrakning.json', JSON.stringify(balanceSheet, null, 2))
rapporter.file('huvudbok.json', JSON.stringify(generalLedger, null, 2))
rapporter.file('grundbok.json', JSON.stringify(journalRegister, null, 2))
// VAT declaration — calculate for the full fiscal period as yearly
try {
const startDate = new Date(period.period_start)
const vatDeclaration = await calculateVatDeclaration(
supabase,
userId,
'yearly',
startDate.getFullYear(),
1
)
rapporter.file('momsdeklaration.json', JSON.stringify(vatDeclaration, null, 2))
} catch {
// VAT declaration may fail if no relevant entries exist — skip gracefully
}
// 3. Documents folder
if (include_documents) {
const dokument = zip.folder('dokument')!
const manifest: DocumentManifestEntry[] = []
// 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')
.eq('user_id', userId)
.not('journal_entry_id', 'is', null)
if (documents && documents.length > 0) {
// Filter to entries in this period
const { data: periodEntryIds } = await supabase
.from('journal_entries')
.select('id')
.eq('user_id', userId)
.eq('fiscal_period_id', period_id)
.in('status', ['posted', 'reversed'])
const periodEntryIdSet = new Set((periodEntryIds || []).map((e: { id: string }) => e.id))
const periodDocuments = documents.filter(
(d: { journal_entry_id: string | null }) => d.journal_entry_id && periodEntryIdSet.has(d.journal_entry_id)
)
for (const doc of periodDocuments) {
try {
const { data: fileData, error } = await supabase.storage
.from('documents')
.download(doc.storage_path)
if (error || !fileData) {
manifest.push({
file_name: doc.file_name,
storage_path: doc.storage_path,
status: 'error',
error: error?.message || 'Download returned no data',
})
continue
}
const buffer = await fileData.arrayBuffer()
dokument.file(doc.file_name, buffer)
manifest.push({
file_name: doc.file_name,
storage_path: doc.storage_path,
status: 'downloaded',
})
} catch (err) {
manifest.push({
file_name: doc.file_name,
storage_path: doc.storage_path,
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error',
})
}
}
}
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
}
// 4. Audit trail
const revision = zip.folder('revision')!
const allAuditEntries: AuditLogEntry[] = []
let page = 1
const pageSize = 500
while (true) {
const result = await getAuditLog(supabase, userId, {
from_date: period.period_start,
to_date: period.period_end,
page,
pageSize,
})
allAuditEntries.push(...result.data)
if (allAuditEntries.length >= result.count || result.data.length < pageSize) {
break
}
page++
}
revision.file('behandlingshistorik.json', JSON.stringify(allAuditEntries, null, 2))
return zip.generateAsync({ type: 'arraybuffer' })
}
@@ -0,0 +1,257 @@
import { describe, it, expect } from 'vitest'
import { INK2_ACCOUNT_MAPPINGS, isAccountInMapping } from '../ink2-engine'
import type { INK2AccountMapping, INK2SRUCode } from '../types'
/**
* Helper to find which SRU code an account maps to
*/
function findSRUCodeForAccount(accountNumber: string): INK2SRUCode | null {
for (const mapping of INK2_ACCOUNT_MAPPINGS) {
if (isAccountInMapping(accountNumber, mapping)) {
return mapping.sruCode
}
}
return null
}
describe('INK2 Account Mappings', () => {
describe('completeness', () => {
it('has 19 mappings covering all INK2 fields', () => {
expect(INK2_ACCOUNT_MAPPINGS).toHaveLength(19)
})
it('covers all SRU codes', () => {
const codes = INK2_ACCOUNT_MAPPINGS.map(m => m.sruCode)
const expectedCodes: INK2SRUCode[] = [
'7201', '7202', '7203', '7210', '7211', '7212',
'7220', '7221', '7222', '7230', '7231',
'7310', '7320', '7330', '7340', '7350', '7360', '7370', '7380',
]
expect(codes).toEqual(expectedCodes)
})
})
describe('Balance sheet - Assets', () => {
it('1000-1099 -> 7201 (Immateriella AT)', () => {
expect(findSRUCodeForAccount('1000')).toBe('7201')
expect(findSRUCodeForAccount('1050')).toBe('7201')
expect(findSRUCodeForAccount('1099')).toBe('7201')
})
it('1100-1299 -> 7202 (Materiella AT)', () => {
expect(findSRUCodeForAccount('1100')).toBe('7202')
expect(findSRUCodeForAccount('1210')).toBe('7202')
expect(findSRUCodeForAccount('1299')).toBe('7202')
})
it('1300-1399 -> 7203 (Finansiella AT)', () => {
expect(findSRUCodeForAccount('1300')).toBe('7203')
expect(findSRUCodeForAccount('1350')).toBe('7203')
expect(findSRUCodeForAccount('1399')).toBe('7203')
})
it('1400-1499 -> 7210 (Varulager)', () => {
expect(findSRUCodeForAccount('1400')).toBe('7210')
expect(findSRUCodeForAccount('1460')).toBe('7210')
expect(findSRUCodeForAccount('1499')).toBe('7210')
})
it('1500-1599 -> 7211 (Kundfordringar)', () => {
expect(findSRUCodeForAccount('1500')).toBe('7211')
expect(findSRUCodeForAccount('1510')).toBe('7211')
expect(findSRUCodeForAccount('1599')).toBe('7211')
})
it('1600-1999 -> 7212 (Övriga OT)', () => {
expect(findSRUCodeForAccount('1600')).toBe('7212')
expect(findSRUCodeForAccount('1930')).toBe('7212')
expect(findSRUCodeForAccount('1999')).toBe('7212')
})
})
describe('Balance sheet - Equity & Liabilities', () => {
it('2081 -> 7220 (Aktiekapital)', () => {
expect(findSRUCodeForAccount('2081')).toBe('7220')
})
it('2081 does NOT go to 7221', () => {
expect(findSRUCodeForAccount('2081')).not.toBe('7221')
})
it('2000-2080 -> 7221 (Övrigt EK)', () => {
expect(findSRUCodeForAccount('2000')).toBe('7221')
expect(findSRUCodeForAccount('2010')).toBe('7221')
expect(findSRUCodeForAccount('2080')).toBe('7221')
})
it('2082-2098 -> 7221 (Övrigt EK)', () => {
expect(findSRUCodeForAccount('2082')).toBe('7221')
expect(findSRUCodeForAccount('2090')).toBe('7221')
expect(findSRUCodeForAccount('2098')).toBe('7221')
})
it('2099 -> 7222 (Årets resultat)', () => {
expect(findSRUCodeForAccount('2099')).toBe('7222')
})
it('2099 does NOT go to 7221', () => {
expect(findSRUCodeForAccount('2099')).not.toBe('7221')
})
it('2100-2499 -> 7230 (Obeskattade reserver, avsättningar, skulder)', () => {
expect(findSRUCodeForAccount('2100')).toBe('7230')
expect(findSRUCodeForAccount('2150')).toBe('7230') // Obeskattade reserver
expect(findSRUCodeForAccount('2250')).toBe('7230') // Avsättningar
expect(findSRUCodeForAccount('2440')).toBe('7230') // Leverantörsskulder
expect(findSRUCodeForAccount('2499')).toBe('7230')
})
it('2500-2999 -> 7231 (Övriga skulder)', () => {
expect(findSRUCodeForAccount('2500')).toBe('7231')
expect(findSRUCodeForAccount('2611')).toBe('7231') // Utgående moms
expect(findSRUCodeForAccount('2710')).toBe('7231') // Personalens källskatt
expect(findSRUCodeForAccount('2999')).toBe('7231')
})
})
describe('Income statement', () => {
it('3000-3999 -> 7310 (Nettoomsättning)', () => {
expect(findSRUCodeForAccount('3000')).toBe('7310')
expect(findSRUCodeForAccount('3001')).toBe('7310')
expect(findSRUCodeForAccount('3100')).toBe('7310')
expect(findSRUCodeForAccount('3999')).toBe('7310')
})
it('4000-4999 -> 7320 (Varuinköp)', () => {
expect(findSRUCodeForAccount('4000')).toBe('7320')
expect(findSRUCodeForAccount('4010')).toBe('7320')
expect(findSRUCodeForAccount('4999')).toBe('7320')
})
it('5000-6999 -> 7330 (Övriga externa kostnader)', () => {
expect(findSRUCodeForAccount('5000')).toBe('7330')
expect(findSRUCodeForAccount('5460')).toBe('7330')
expect(findSRUCodeForAccount('6200')).toBe('7330')
expect(findSRUCodeForAccount('6999')).toBe('7330')
})
it('7000-7699 -> 7340 (Personalkostnader)', () => {
expect(findSRUCodeForAccount('7000')).toBe('7340')
expect(findSRUCodeForAccount('7210')).toBe('7340')
expect(findSRUCodeForAccount('7699')).toBe('7340')
})
it('7700-7899 -> 7350 (Avskrivningar)', () => {
expect(findSRUCodeForAccount('7700')).toBe('7350')
expect(findSRUCodeForAccount('7820')).toBe('7350')
expect(findSRUCodeForAccount('7899')).toBe('7350')
})
it('7900-7999 -> 7360 (Övriga rörelsekostnader)', () => {
expect(findSRUCodeForAccount('7900')).toBe('7360')
expect(findSRUCodeForAccount('7970')).toBe('7360')
expect(findSRUCodeForAccount('7999')).toBe('7360')
})
it('8000-8499 -> 7370 (Finansiella poster)', () => {
expect(findSRUCodeForAccount('8000')).toBe('7370')
expect(findSRUCodeForAccount('8310')).toBe('7370') // Ränteintäkter
expect(findSRUCodeForAccount('8400')).toBe('7370') // Räntekostnader
expect(findSRUCodeForAccount('8499')).toBe('7370')
})
it('8500-8999 -> 7380 (Extraordinära poster)', () => {
expect(findSRUCodeForAccount('8500')).toBe('7380')
expect(findSRUCodeForAccount('8910')).toBe('7380') // Skatt
expect(findSRUCodeForAccount('8999')).toBe('7380')
})
})
describe('no overlap between mappings', () => {
it('each account matches exactly one mapping', () => {
// Test a representative sample across boundaries
const testAccounts = [
'1099', '1100', // 7201/7202 boundary
'1299', '1300', // 7202/7203 boundary
'1399', '1400', // 7203/7210 boundary
'1499', '1500', // 7210/7211 boundary
'1599', '1600', // 7211/7212 boundary
'1999', '2000', // 7212/7221 boundary
'2080', '2081', '2082', // 7221/7220/7221
'2098', '2099', '2100', // 7221/7222/7230 boundary
'2499', '2500', // 7230/7231 boundary
'2999', '3000', // 7231/7310 boundary
'3999', '4000', // 7310/7320 boundary
'4999', '5000', // 7320/7330 boundary
'6999', '7000', // 7330/7340 boundary
'7699', '7700', // 7340/7350 boundary
'7899', '7900', // 7350/7360 boundary
'7999', '8000', // 7360/7370 boundary
'8499', '8500', // 7370/7380 boundary
]
for (const account of testAccounts) {
let matchCount = 0
for (const mapping of INK2_ACCOUNT_MAPPINGS) {
if (isAccountInMapping(account, mapping)) {
matchCount++
}
}
expect(matchCount).toBe(1)
}
})
})
describe('section assignments', () => {
it('asset mappings have section "assets"', () => {
const assetMappings = INK2_ACCOUNT_MAPPINGS.filter(m => m.section === 'assets')
expect(assetMappings.map(m => m.sruCode)).toEqual(['7201', '7202', '7203', '7210', '7211', '7212'])
})
it('equity/liability mappings have section "equity_liabilities"', () => {
const eqMappings = INK2_ACCOUNT_MAPPINGS.filter(m => m.section === 'equity_liabilities')
expect(eqMappings.map(m => m.sruCode)).toEqual(['7220', '7221', '7222', '7230', '7231'])
})
it('income statement mappings have section "income_statement"', () => {
const isMappings = INK2_ACCOUNT_MAPPINGS.filter(m => m.section === 'income_statement')
expect(isMappings.map(m => m.sruCode)).toEqual(['7310', '7320', '7330', '7340', '7350', '7360', '7370', '7380'])
})
})
describe('normal balance assignments', () => {
it('asset accounts are debit-normal', () => {
const assetMappings = INK2_ACCOUNT_MAPPINGS.filter(m => m.section === 'assets')
for (const m of assetMappings) {
expect(m.normalBalance).toBe('debit')
}
})
it('equity/liability accounts are credit-normal', () => {
const eqMappings = INK2_ACCOUNT_MAPPINGS.filter(m => m.section === 'equity_liabilities')
for (const m of eqMappings) {
expect(m.normalBalance).toBe('credit')
}
})
it('revenue (7310) is credit-normal', () => {
const revenue = INK2_ACCOUNT_MAPPINGS.find(m => m.sruCode === '7310')
expect(revenue?.normalBalance).toBe('credit')
})
it('expense accounts (7320-7360) are debit-normal', () => {
const expenseCodes: INK2SRUCode[] = ['7320', '7330', '7340', '7350', '7360']
for (const code of expenseCodes) {
const mapping = INK2_ACCOUNT_MAPPINGS.find(m => m.sruCode === code)
expect(mapping?.normalBalance).toBe('debit')
}
})
it('financial and extraordinary items (7370, 7380) are net', () => {
const financial = INK2_ACCOUNT_MAPPINGS.find(m => m.sruCode === '7370')
const extraordinary = INK2_ACCOUNT_MAPPINGS.find(m => m.sruCode === '7380')
expect(financial?.normalBalance).toBe('net')
expect(extraordinary?.normalBalance).toBe('net')
})
})
})
@@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest'
import { generateSRUFile, sruFileToString, validateSRUFile, getSRUFilename } from '../sru-generator'
import type { INK2Declaration } from '../types'
function makeDeclaration(overrides?: Partial<INK2Declaration>): INK2Declaration {
return {
fiscalYear: {
id: 'period-1',
name: 'Räkenskapsår 2025',
start: '2025-01-01',
end: '2025-12-31',
isClosed: true,
},
rutor: {
'7201': 0, '7202': 50000, '7203': 0,
'7210': 10000, '7211': 25000, '7212': 100000,
'7220': 50000, '7221': 20000, '7222': 15000,
'7230': 30000, '7231': 70000,
'7310': 500000, '7320': 200000, '7330': 100000,
'7340': 80000, '7350': 10000, '7360': 5000,
'7370': -3000, '7380': 0,
},
breakdown: {} as INK2Declaration['breakdown'],
totals: {
totalAssets: 185000,
totalEquityLiabilities: 185000,
operatingResult: 105000,
resultAfterFinancial: 102000,
},
companyInfo: {
companyName: 'Test AB',
orgNumber: '556677-8899',
},
warnings: [],
...overrides,
}
}
describe('INK2 SRU Generator', () => {
describe('generateSRUFile', () => {
it('produces valid SRU file structure', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const validation = validateSRUFile(sruFile)
expect(validation.isValid).toBe(true)
expect(validation.errors).toEqual([])
})
it('uses #BLANKETT INK2', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const blankettRecord = sruFile.records.find(r => r.fieldCode === 'BLANKETT')
expect(blankettRecord?.value).toBe('INK2')
})
it('includes only non-zero field values', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const uppgiftRecords = sruFile.records.filter(r => r.fieldCode === 'UPPGIFT')
// 7000 (fiscal year) + non-zero rutor
// Zero rutor: 7201, 7203, 7380 = 3 zero fields
// Non-zero: 16 fields
// Total UPPGIFT records: 1 (fiscal year) + 16 (non-zero values)
expect(uppgiftRecords).toHaveLength(17)
// Verify zero fields are excluded
const fieldCodes = uppgiftRecords.map(r => String(r.value).split(' ')[0])
expect(fieldCodes).not.toContain('7201')
expect(fieldCodes).not.toContain('7203')
expect(fieldCodes).not.toContain('7380')
})
it('includes fiscal year as field 7000', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const fiscalYearRecord = sruFile.records.find(
r => r.fieldCode === 'UPPGIFT' && String(r.value).startsWith('7000')
)
expect(fiscalYearRecord).toBeDefined()
expect(fiscalYearRecord?.value).toBe('7000 20250101-20251231')
})
it('handles negative values (financial items)', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const financialRecord = sruFile.records.find(
r => r.fieldCode === 'UPPGIFT' && String(r.value).startsWith('7370')
)
expect(financialRecord?.value).toBe('7370 -3000')
})
it('strips dashes from org number', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const identityRecord = sruFile.records.find(r => r.fieldCode === 'IDENTITET')
expect(identityRecord?.value).toBe('5566778899')
})
})
describe('sruFileToString', () => {
it('formats records as #FIELD value lines', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const content = sruFileToString(sruFile)
expect(content).toContain('#BLANKETT INK2')
expect(content).toContain('#IDENTITET 5566778899')
expect(content).toContain('#BLANKETTSLUT')
})
it('uses CRLF line endings', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const content = sruFileToString(sruFile)
expect(content).toContain('\r\n')
})
it('ends with newline', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const content = sruFileToString(sruFile)
expect(content.endsWith('\r\n')).toBe(true)
})
})
describe('getSRUFilename', () => {
it('returns correct filename format', () => {
const declaration = makeDeclaration()
expect(getSRUFilename(declaration)).toBe('INK2_5566778899_2025.sru')
})
it('handles missing org number', () => {
const declaration = makeDeclaration({
companyInfo: { companyName: 'Test AB', orgNumber: null },
})
expect(getSRUFilename(declaration)).toBe('INK2_unknown_2025.sru')
})
})
describe('validateSRUFile', () => {
it('validates a correct SRU file', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const result = validateSRUFile(sruFile)
expect(result.isValid).toBe(true)
})
it('detects missing PRODUKT header', () => {
const result = validateSRUFile({
records: [
{ fieldCode: 'BLANKETT', value: 'INK2' },
{ fieldCode: 'BLANKETTSLUT', value: '' },
],
generatedAt: new Date().toISOString(),
})
expect(result.isValid).toBe(false)
expect(result.errors).toContain('Missing PRODUKT header')
})
it('detects wrong blankett type', () => {
const result = validateSRUFile({
records: [
{ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' },
{ fieldCode: 'BLANKETT', value: 'NE' },
{ fieldCode: 'BLANKETTSLUT', value: '' },
],
generatedAt: new Date().toISOString(),
})
expect(result.isValid).toBe(false)
expect(result.errors).toContain('Expected BLANKETT INK2, got NE')
})
})
})
+386
View File
@@ -0,0 +1,386 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type {
FiscalPeriod,
JournalEntry,
JournalEntryLine,
} from '@/types'
import type {
INK2Declaration,
INK2DeclarationRutor,
INK2AccountMapping,
INK2SRUCode,
} from './types'
/**
* INK2 (Aktiebolag / Limited Company Declaration)
*
* Maps BAS account balances to INK2 declaration fields (SRU 7201-7380)
* for tax reporting to Skatteverket.
*
* This generates the bokföringsmässigt resultat (accounting result).
* Skattemässiga justeringar (INK2S) are handled by the accountant.
*
* Account mappings use engine-internal range-based logic, NOT the DB
* sru_code column, because the DB column is NE-biased for class 3-8.
*/
/**
* Account mapping configuration for INK2 declaration
*/
export const INK2_ACCOUNT_MAPPINGS: INK2AccountMapping[] = [
// Balance sheet - Assets
{
sruCode: '7201',
description: 'Immateriella anläggningstillgångar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1000', end: '1099' }],
},
{
sruCode: '7202',
description: 'Materiella anläggningstillgångar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1100', end: '1299' }],
},
{
sruCode: '7203',
description: 'Finansiella anläggningstillgångar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1300', end: '1399' }],
},
{
sruCode: '7210',
description: 'Varulager m.m.',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1400', end: '1499' }],
},
{
sruCode: '7211',
description: 'Kundfordringar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1500', end: '1599' }],
},
{
sruCode: '7212',
description: 'Övriga omsättningstillgångar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1600', end: '1999' }],
},
// Balance sheet - Equity & Liabilities
{
sruCode: '7220',
description: 'Aktiekapital',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2081', end: '2081' }],
},
{
sruCode: '7221',
description: 'Övrigt eget kapital',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [
{ start: '2000', end: '2080' },
{ start: '2082', end: '2098' },
],
},
{
sruCode: '7222',
description: 'Årets resultat',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2099', end: '2099' }],
},
{
sruCode: '7230',
description: 'Obeskattade reserver, avsättningar och skulder',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2100', end: '2499' }],
},
{
sruCode: '7231',
description: 'Övriga skulder',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2500', end: '2999' }],
},
// Income statement
{
sruCode: '7310',
description: 'Nettoomsättning',
section: 'income_statement',
normalBalance: 'credit',
accountRanges: [{ start: '3000', end: '3999' }],
},
{
sruCode: '7320',
description: 'Varuinköp/direkta kostnader',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '4000', end: '4999' }],
},
{
sruCode: '7330',
description: 'Övriga externa kostnader',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '5000', end: '6999' }],
},
{
sruCode: '7340',
description: 'Personalkostnader',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '7000', end: '7699' }],
},
{
sruCode: '7350',
description: 'Avskrivningar',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '7700', end: '7899' }],
},
{
sruCode: '7360',
description: 'Övriga rörelsekostnader',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '7900', end: '7999' }],
},
{
sruCode: '7370',
description: 'Finansiella poster (netto)',
section: 'income_statement',
normalBalance: 'net',
accountRanges: [{ start: '8000', end: '8499' }],
},
{
sruCode: '7380',
description: 'Extraordinära poster (netto)',
section: 'income_statement',
normalBalance: 'net',
accountRanges: [{ start: '8500', end: '8999' }],
},
]
/**
* Check if an account number falls within a mapping's ranges
*/
export function isAccountInMapping(accountNumber: string, mapping: INK2AccountMapping): boolean {
for (const range of mapping.accountRanges) {
if (accountNumber >= range.start && accountNumber <= range.end) {
if (range.exclude && range.exclude.includes(accountNumber)) {
continue
}
return true
}
}
return false
}
/**
* Round to nearest krona (whole number) for INK2 declaration
*/
function roundToKrona(value: number): number {
return Math.round(value)
}
/**
* Generate INK2 declaration for a fiscal period
*/
export async function generateINK2Declaration(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<INK2Declaration> {
// Fetch fiscal period
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.single()
if (periodError || !period) {
throw new Error('Fiscal period not found')
}
// Fetch company settings
const { data: settings } = await supabase
.from('company_settings')
.select('company_name, org_number, entity_type')
.eq('user_id', userId)
.single()
// Validate entity type
if (settings?.entity_type !== 'aktiebolag') {
throw new Error('INK2 declaration is only for aktiebolag (limited company)')
}
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', userId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
if (entriesError) {
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
}
// Fetch chart of accounts for account names
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.range(from, to)
)
const accountNameMap = new Map<string, string>()
for (const acc of accounts) {
accountNameMap.set(acc.account_number, acc.account_name)
}
// Calculate balances per account (debit - credit)
const accountBalances = new Map<string, number>()
for (const entry of (entries as JournalEntry[]) || []) {
const lines = (entry.lines as JournalEntryLine[]) || []
for (const line of lines) {
const current = accountBalances.get(line.account_number) || 0
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
accountBalances.set(line.account_number, current + netAmount)
}
}
// Initialize rutor
const rutor: INK2DeclarationRutor = {
'7201': 0, '7202': 0, '7203': 0, '7210': 0, '7211': 0, '7212': 0,
'7220': 0, '7221': 0, '7222': 0, '7230': 0, '7231': 0,
'7310': 0, '7320': 0, '7330': 0, '7340': 0, '7350': 0, '7360': 0, '7370': 0, '7380': 0,
}
const allCodes: INK2SRUCode[] = Object.keys(rutor) as INK2SRUCode[]
const breakdown = {} as INK2Declaration['breakdown']
for (const code of allCodes) {
breakdown[code] = { accounts: [], total: 0 }
}
const warnings: string[] = []
// Process each account balance
for (const [accountNumber, balance] of accountBalances) {
if (Math.abs(balance) < 0.01) continue
for (const mapping of INK2_ACCOUNT_MAPPINGS) {
if (isAccountInMapping(accountNumber, mapping)) {
let amount: number
if (mapping.normalBalance === 'debit') {
// Asset/expense accounts: debit normal, balance is already positive for debit
amount = balance
} else if (mapping.normalBalance === 'credit') {
// Equity/liability/revenue accounts: credit normal, negate to show as positive
amount = -balance
} else {
// Net fields (7370, 7380): negate so positive = net income, negative = net cost
amount = -balance
}
rutor[mapping.sruCode] += amount
breakdown[mapping.sruCode].accounts.push({
accountNumber,
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
amount: roundToKrona(amount),
})
break
}
}
}
// Round all rutor to whole krona
for (const code of allCodes) {
rutor[code] = roundToKrona(rutor[code])
breakdown[code].total = rutor[code]
}
// Calculate derived totals
const totalAssets = rutor['7201'] + rutor['7202'] + rutor['7203'] +
rutor['7210'] + rutor['7211'] + rutor['7212']
const totalEquityLiabilities = rutor['7220'] + rutor['7221'] + rutor['7222'] +
rutor['7230'] + rutor['7231']
// Operating result = revenue - operating costs
const operatingResult = rutor['7310'] -
rutor['7320'] - rutor['7330'] - rutor['7340'] -
rutor['7350'] - rutor['7360']
// Result after financial items
const resultAfterFinancial = operatingResult + rutor['7370'] + rutor['7380']
// Add warnings
if (!(period as FiscalPeriod).is_closed) {
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
}
if (totalAssets === 0 && totalEquityLiabilities === 0 && rutor['7310'] === 0) {
warnings.push('Inga bokförda transaktioner hittades för perioden.')
}
const balanceDiff = Math.abs(totalAssets - totalEquityLiabilities)
if (balanceDiff > 0 && totalAssets > 0) {
warnings.push(
`Balansräkningen är inte i balans. Tillgångar: ${totalAssets} kr, Eget kapital och skulder: ${totalEquityLiabilities} kr (differens: ${balanceDiff} kr).`
)
}
return {
fiscalYear: {
id: period.id,
name: period.name,
start: period.period_start,
end: period.period_end,
isClosed: period.is_closed,
},
rutor,
breakdown,
totals: {
totalAssets,
totalEquityLiabilities,
operatingResult,
resultAfterFinancial,
},
companyInfo: {
companyName: settings?.company_name || 'Okänt företag',
orgNumber: settings?.org_number || null,
},
warnings,
}
}
/**
* Get totals for display
*/
export function getINK2DeclarationTotals(declaration: INK2Declaration): {
totalAssets: number
totalEquityLiabilities: number
operatingResult: number
resultAfterFinancial: number
} {
return declaration.totals
}
+158
View File
@@ -0,0 +1,158 @@
import type { INK2Declaration, INK2SRUCode, SRUFile, SRURecord } from './types'
/**
* SRU File Generator for INK2
*
* Generates SRU (Standardiserat Räkenskapsutdrag) files for electronic
* submission to Skatteverket. The SRU format is used for tax declarations.
*
* INK2 field codes are the SRU codes directly (7201-7380).
*/
/** All INK2 SRU field codes in order */
const INK2_FIELD_CODES: INK2SRUCode[] = [
'7201', '7202', '7203', '7210', '7211', '7212',
'7220', '7221', '7222', '7230', '7231',
'7310', '7320', '7330', '7340', '7350', '7360', '7370', '7380',
]
/**
* Generate SRU file content from INK2 declaration
*/
export function generateSRUFile(declaration: INK2Declaration): SRUFile {
const records: SRURecord[] = []
const now = new Date()
// File header
records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' })
records.push({ fieldCode: 'SESSION', value: '1' })
records.push({ fieldCode: 'PROGRAMNAMN', value: 'ERPBase' })
records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' })
records.push({
fieldCode: 'SKAPAT',
value: formatSRUDate(now),
})
// Form declaration
records.push({ fieldCode: 'BLANKETT', value: 'INK2' })
// Company identification
if (declaration.companyInfo.orgNumber) {
const cleanOrgNumber = declaration.companyInfo.orgNumber.replace(/-/g, '')
records.push({
fieldCode: 'IDENTITET',
value: cleanOrgNumber,
})
}
// Fiscal year
records.push({
fieldCode: 'UPPGIFT',
value: `7000 ${formatSRUDateRange(declaration.fiscalYear.start, declaration.fiscalYear.end)}`,
})
// INK2 field values
for (const code of INK2_FIELD_CODES) {
const value = declaration.rutor[code]
if (value !== 0) {
records.push({
fieldCode: 'UPPGIFT',
value: `${code} ${formatSRUAmount(value)}`,
})
}
}
// End of form
records.push({ fieldCode: 'BLANKETTSLUT', value: '' })
return {
records,
generatedAt: now.toISOString(),
}
}
/**
* Convert SRU file to string content
*/
export function sruFileToString(sruFile: SRUFile): string {
const lines: string[] = []
for (const record of sruFile.records) {
if (record.value === '') {
lines.push(`#${record.fieldCode}`)
} else {
lines.push(`#${record.fieldCode} ${record.value}`)
}
}
return lines.join('\r\n') + '\r\n'
}
/**
* Format date for SRU: YYYYMMDD
*/
function formatSRUDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
}
/**
* Format date string (YYYY-MM-DD) to SRU format (YYYYMMDD)
*/
function dateStringToSRU(dateStr: string): string {
return dateStr.replace(/-/g, '')
}
/**
* Format fiscal year date range for SRU
*/
function formatSRUDateRange(startDate: string, endDate: string): string {
return `${dateStringToSRU(startDate)}-${dateStringToSRU(endDate)}`
}
/**
* Format amount for SRU: whole numbers, no thousands separator, negative with minus
*/
function formatSRUAmount(amount: number): string {
return Math.round(amount).toString()
}
/**
* Validate SRU file content
*/
export function validateSRUFile(sruFile: SRUFile): {
isValid: boolean
errors: string[]
} {
const errors: string[] = []
const hasHeader = sruFile.records.some(r => r.fieldCode === 'PRODUKT')
const hasBlankett = sruFile.records.some(r => r.fieldCode === 'BLANKETT')
const hasBlankettslut = sruFile.records.some(r => r.fieldCode === 'BLANKETTSLUT')
if (!hasHeader) errors.push('Missing PRODUKT header')
if (!hasBlankett) errors.push('Missing BLANKETT declaration')
if (!hasBlankettslut) errors.push('Missing BLANKETTSLUT')
// Verify it's INK2
const blankettRecord = sruFile.records.find(r => r.fieldCode === 'BLANKETT')
if (blankettRecord && blankettRecord.value !== 'INK2') {
errors.push(`Expected BLANKETT INK2, got ${blankettRecord.value}`)
}
return {
isValid: errors.length === 0,
errors,
}
}
/**
* Get filename for SRU file download
*/
export function getSRUFilename(declaration: INK2Declaration): string {
const year = declaration.fiscalYear.start.substring(0, 4)
const orgNumber = declaration.companyInfo.orgNumber?.replace(/-/g, '') || 'unknown'
return `INK2_${orgNumber}_${year}.sru`
}
+104
View File
@@ -0,0 +1,104 @@
// INK2 declaration rutor (fields) keyed by SRU code
export interface INK2DeclarationRutor {
// Balance sheet - Assets
'7201': number // Immateriella anläggningstillgångar
'7202': number // Materiella anläggningstillgångar
'7203': number // Finansiella anläggningstillgångar
'7210': number // Varulager m.m.
'7211': number // Kundfordringar
'7212': number // Övriga omsättningstillgångar
// Balance sheet - Equity & Liabilities
'7220': number // Aktiekapital
'7221': number // Övrigt eget kapital
'7222': number // Årets resultat
'7230': number // Obeskattade reserver, avsättningar och skulder
'7231': number // Övriga skulder
// Income statement
'7310': number // Nettoomsättning
'7320': number // Varuinköp/direkta kostnader
'7330': number // Övriga externa kostnader
'7340': number // Personalkostnader
'7350': number // Avskrivningar
'7360': number // Övriga rörelsekostnader
'7370': number // Finansiella poster (netto)
'7380': number // Extraordinära poster (netto)
}
export type INK2SRUCode = keyof INK2DeclarationRutor
// Account mapping configuration for INK2 declaration
export interface INK2AccountMapping {
sruCode: INK2SRUCode
description: string
section: 'assets' | 'equity_liabilities' | 'income_statement'
normalBalance: 'debit' | 'credit' | 'net'
accountRanges: Array<{
start: string
end: string
exclude?: string[]
}>
}
// INK2 declaration response
export interface INK2Declaration {
fiscalYear: {
id: string
name: string
start: string
end: string
isClosed: boolean
}
rutor: INK2DeclarationRutor
breakdown: Record<INK2SRUCode, {
accounts: Array<{
accountNumber: string
accountName: string
amount: number
}>
total: number
}>
totals: {
totalAssets: number
totalEquityLiabilities: number
operatingResult: number
resultAfterFinancial: number
}
companyInfo: {
companyName: string
orgNumber: string | null
}
warnings: string[]
}
// Reuse SRU file types from NE-bilaga
export type { SRURecord, SRUFile } from '@/lib/reports/ne-bilaga/types'
// Labels for INK2 rutor
export const INK2_RUTA_LABELS: Record<INK2SRUCode, string> = {
'7201': 'Immateriella anläggningstillgångar',
'7202': 'Materiella anläggningstillgångar',
'7203': 'Finansiella anläggningstillgångar',
'7210': 'Varulager m.m.',
'7211': 'Kundfordringar',
'7212': 'Övriga omsättningstillgångar',
'7220': 'Aktiekapital',
'7221': 'Övrigt eget kapital',
'7222': 'Årets resultat',
'7230': 'Obeskattade reserver, avsättningar och skulder',
'7231': 'Övriga skulder',
'7310': 'Nettoomsättning',
'7320': 'Varuinköp/direkta kostnader',
'7330': 'Övriga externa kostnader',
'7340': 'Personalkostnader',
'7350': 'Avskrivningar',
'7360': 'Övriga rörelsekostnader',
'7370': 'Finansiella poster (netto)',
'7380': 'Extraordinära poster (netto)',
}
// Section groupings for UI display
export const INK2_ASSET_CODES: INK2SRUCode[] = ['7201', '7202', '7203', '7210', '7211', '7212']
export const INK2_EQUITY_LIABILITY_CODES: INK2SRUCode[] = ['7220', '7221', '7222', '7230', '7231']
export const INK2_INCOME_STATEMENT_CODES: INK2SRUCode[] = ['7310', '7320', '7330', '7340', '7350', '7360', '7370', '7380']
+35 -16
View File
@@ -20,21 +20,26 @@ import type {
*/
/**
* Account-to-ruta mapping for the Swedish momsdeklaration.
* Account-to-ruta mapping for the Swedish momsdeklaration (SKV 4700).
*
* Output VAT (26xx): net credit balance feeds output VAT boxes.
* Revenue (3001/3002/3003): net credit balance feeds ruta 05 (total domestic taxable sales).
* Output VAT (2611/2621/2631): net credit balance feeds ruta 10/11/12 (output VAT per rate).
* Input VAT (2641/2645): net debit balance feeds ruta 48.
* Revenue (3xxx): net credit balance feeds underlag (basis) boxes.
* EU/Export (3308/3305): net credit balance feeds ruta 39/40.
*/
const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'credit' | 'debit' }> = {
'2611': { box: 'ruta05', side: 'credit' },
'2621': { box: 'ruta06', side: 'credit' },
'2631': { box: 'ruta07', side: 'credit' },
// Output VAT accounts → ruta 10/11/12
'2611': { box: 'ruta10', side: 'credit' },
'2621': { box: 'ruta11', side: 'credit' },
'2631': { box: 'ruta12', side: 'credit' },
// Input VAT → ruta 48
'2641': { box: 'ruta48', side: 'debit' },
'2645': { box: 'ruta48', side: 'debit' },
'3001': { box: 'ruta10', side: 'credit' },
'3002': { box: 'ruta11', side: 'credit' },
'3003': { box: 'ruta12', side: 'credit' },
// Revenue accounts → ruta 05 (all domestic taxable sales combined)
'3001': { box: 'ruta05', side: 'credit' },
'3002': { box: 'ruta05', side: 'credit' },
'3003': { box: 'ruta05', side: 'credit' },
// EU/Export → ruta 39/40
'3305': { box: 'ruta40', side: 'credit' },
'3308': { box: 'ruta39', side: 'credit' },
}
@@ -103,11 +108,11 @@ function round(value: number): number {
* Calculate VAT declaration from the general ledger.
*
* Sums posted journal entry lines on 26xx and 3xxx accounts:
* - 2611/2621/2631 credit balance -> ruta 05/06/07 (output VAT)
* - 3001/3002/3003 credit balance -> ruta 05 (total domestic taxable sales)
* - 2611/2621/2631 credit balance -> ruta 10/11/12 (output VAT per rate)
* - 2641/2645 debit balance -> ruta 48 (input VAT)
* - 3001/3002/3003 credit balance -> ruta 10/11/12 (revenue basis)
* - 3308/3305 credit balance -> ruta 39/40 (EU/export)
* - ruta 49 = (05 + 06 + 07) - 48
* - ruta 49 = (10 + 11 + 12) - 48
*
* The accounting method parameter is accepted for backward compatibility
* but not used — the method is already baked into journal entry timing.
@@ -170,7 +175,18 @@ export async function calculateVatDeclaration(
rutor[mapping.box] = round(rutor[mapping.box] + balance)
}
rutor.ruta49 = round(rutor.ruta05 + rutor.ruta06 + rutor.ruta07 - rutor.ruta48)
rutor.ruta49 = round(rutor.ruta10 + rutor.ruta11 + rutor.ruta12 - rutor.ruta48)
// Compute per-rate base amounts from individual revenue accounts
const revenueByRate = {
base25: 0, // 3001
base12: 0, // 3002
base6: 0, // 3003
}
for (const [account, rate] of [['3001', 'base25'], ['3002', 'base12'], ['3003', 'base6']] as const) {
const t = totals.get(account)
if (t) revenueByRate[rate] = round(t.credit - t.debit)
}
// Count journal entries by source type for metadata
const { data: entryCounts } = await supabase
@@ -206,6 +222,9 @@ export async function calculateVatDeclaration(
ruta12: rutor.ruta12,
ruta39: rutor.ruta39,
ruta40: rutor.ruta40,
base25: revenueByRate.base25,
base12: revenueByRate.base12,
base6: revenueByRate.base6,
},
transactions: { ruta48: rutor.ruta48 },
receipts: { ruta48: 0 },
@@ -223,9 +242,9 @@ export function getVatDeclarationSummary(declaration: VatDeclaration): {
isRefund: boolean
} {
const totalOutputVat = round(
declaration.rutor.ruta05 +
declaration.rutor.ruta06 +
declaration.rutor.ruta07
declaration.rutor.ruta10 +
declaration.rutor.ruta11 +
declaration.rutor.ruta12
)
const totalInputVat = declaration.rutor.ruta48
+82
View File
@@ -39,6 +39,7 @@
"framer-motion": "^12.29.2",
"fuse.js": "^7.1.0",
"ics": "^3.8.1",
"jszip": "^3.10.1",
"langchain": "^1.2.16",
"lucide-react": "^0.563.0",
"next": "16.1.5",
@@ -7520,6 +7521,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -9569,6 +9576,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
@@ -10348,6 +10361,18 @@
"node": ">=4.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -10523,6 +10548,15 @@
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -12413,6 +12447,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -12676,6 +12716,42 @@
}
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/readable-stream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
@@ -13219,6 +13295,12 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+1
View File
@@ -44,6 +44,7 @@
"framer-motion": "^12.29.2",
"fuse.js": "^7.1.0",
"ics": "^3.8.1",
"jszip": "^3.10.1",
"langchain": "^1.2.16",
"lucide-react": "^0.563.0",
"next": "16.1.5",
+20 -16
View File
@@ -1550,17 +1550,17 @@ export const RECEIPT_STATUS_LABELS: Record<ReceiptStatus, string> = {
// VAT period type
export type VatPeriodType = 'monthly' | 'quarterly' | 'yearly'
// VAT declaration rutor (boxes) according to Swedish tax authority
// VAT declaration rutor (boxes) according to SKV 4700
export interface VatDeclarationRutor {
// Utgående moms (Output VAT)
ruta05: number // Utgående moms 25%
ruta06: number // Utgående moms 12%
ruta07: number // Utgående moms 6%
// Momspliktig försäljning (taxable sales basis, all rates combined)
ruta05: number // Momspliktig försäljning (excl. ruta 06, 07, 08)
ruta06: number // Momspliktiga uttag (unused, always 0)
ruta07: number // Vinstmarginalbeskattning (unused, always 0)
// Momspliktigt underlag (VAT-liable base amounts)
ruta10: number // Momspliktigt underlag 25%
ruta11: number // Momspliktigt underlag 12%
ruta12: number // Momspliktigt underlag 6%
// Utgående moms (Output VAT per rate)
ruta10: number // Utgående moms 25%
ruta11: number // Utgående moms 12%
ruta12: number // Utgående moms 6%
// EU och export
ruta39: number // Försäljning av tjänster till annat EU-land (reverse charge)
@@ -1597,6 +1597,10 @@ export interface VatDeclaration {
ruta12: number
ruta39: number
ruta40: number
// Per-rate base amounts for UI display
base25: number
base12: number
base6: number
}
transactions: {
ruta48: number // Ingående moms from categorized expenses
@@ -1616,16 +1620,16 @@ export interface VatDeclarationRequest {
// Labels for VAT rutor
export const VAT_RUTA_LABELS: Record<keyof VatDeclarationRutor, string> = {
ruta05: 'Utgående moms 25%',
ruta06: 'Utgående moms 12%',
ruta07: 'Utgående moms 6%',
ruta10: 'Momspliktigt underlag 25%',
ruta11: 'Momspliktigt underlag 12%',
ruta12: 'Momspliktigt underlag 6%',
ruta05: 'Momspliktig försäljning',
ruta06: 'Momspliktiga uttag',
ruta07: 'Vinstmarginalbeskattning',
ruta10: 'Utgående moms 25%',
ruta11: 'Utgående moms 12%',
ruta12: 'Utgående moms 6%',
ruta39: 'Försäljning av tjänster till EU-land',
ruta40: 'Export utanför EU',
ruta48: 'Ingående moms att dra av',
ruta49: 'Moms att betala/återfå'
ruta49: 'Moms att betala/återfå',
}
// ============================================================