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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 ("DPA") ingar mellan:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Personuppgiftsansvarig ("den Ansvarige"):</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 ("Bitradet"):</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>
|
||||
)
|
||||
}
|
||||
@@ -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 ("vi", "oss") 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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 på 25% extra på dina fakturor, men får också dra av moms på 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 på 25% extra på dina fakturor, men får också dra av moms på 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>
|
||||
|
||||
@@ -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 på 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 "Hämta INK2" 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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
|
||||
|
||||
Generated
+82
@@ -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",
|
||||
|
||||
@@ -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
@@ -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å',
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user