From 29240738fa9b46a03ed7536ccf2b6dc0397e086a Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 5 Mar 2026 14:20:47 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 32 +- app/(dashboard)/reports/page.tsx | 49 ++- app/(public)/dpa/page.tsx | 185 +++++++++ app/(public)/privacy/page.tsx | 237 +++++++++++ app/api/ai-consent/__tests__/route.test.ts | 135 ++++++ app/api/ai-consent/route.ts | 69 ++++ app/api/audit-trail/__tests__/route.test.ts | 106 +++++ app/api/audit-trail/route.ts | 35 ++ app/api/extensions/ext/[...path]/route.ts | 12 + .../audit-trail/__tests__/route.test.ts | 133 ++++++ app/api/reports/audit-trail/route.ts | 94 +++++ app/api/reports/full-archive/route.ts | 38 ++ app/api/reports/ink2/route.ts | 63 +++ components/extensions/AiConsentDialog.tsx | 102 +++++ .../onboarding/Step3TaxRegistration.tsx | 6 +- components/reports/INK2DeclarationView.tsx | 293 +++++++++++++ components/reports/VatCompositionChart.tsx | 6 +- extensions/general/ai-chat/chatbot/tools.ts | 13 +- lib/extensions/__tests__/ai-consent.test.ts | 119 ++++++ lib/extensions/ai-consent.ts | 130 ++++++ .../__tests__/full-archive-export.test.ts | 236 +++++++++++ lib/reports/__tests__/vat-declaration.test.ts | 60 +-- lib/reports/full-archive-export.ts | 191 +++++++++ .../ink2/__tests__/ink2-engine.test.ts | 257 ++++++++++++ .../ink2/__tests__/sru-generator.test.ts | 174 ++++++++ lib/reports/ink2/ink2-engine.ts | 386 ++++++++++++++++++ lib/reports/ink2/sru-generator.ts | 158 +++++++ lib/reports/ink2/types.ts | 104 +++++ lib/reports/vat-declaration.ts | 51 ++- package-lock.json | 82 ++++ package.json | 1 + types/index.ts | 36 +- 32 files changed, 3496 insertions(+), 97 deletions(-) create mode 100644 app/(public)/dpa/page.tsx create mode 100644 app/(public)/privacy/page.tsx create mode 100644 app/api/ai-consent/__tests__/route.test.ts create mode 100644 app/api/ai-consent/route.ts create mode 100644 app/api/audit-trail/__tests__/route.test.ts create mode 100644 app/api/audit-trail/route.ts create mode 100644 app/api/reports/audit-trail/__tests__/route.test.ts create mode 100644 app/api/reports/audit-trail/route.ts create mode 100644 app/api/reports/full-archive/route.ts create mode 100644 app/api/reports/ink2/route.ts create mode 100644 components/extensions/AiConsentDialog.tsx create mode 100644 components/reports/INK2DeclarationView.tsx create mode 100644 lib/extensions/__tests__/ai-consent.test.ts create mode 100644 lib/extensions/ai-consent.ts create mode 100644 lib/reports/__tests__/full-archive-export.test.ts create mode 100644 lib/reports/full-archive-export.ts create mode 100644 lib/reports/ink2/__tests__/ink2-engine.test.ts create mode 100644 lib/reports/ink2/__tests__/sru-generator.test.ts create mode 100644 lib/reports/ink2/ink2-engine.ts create mode 100644 lib/reports/ink2/sru-generator.ts create mode 100644 lib/reports/ink2/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0e9949bb..49ed9f57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 9e4a159c..f3172b99 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -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 (
@@ -135,6 +137,11 @@ export default function ReportsPage() { NE-bilaga )} + {isAktiebolag && ( + + INK2 + + )}
@@ -189,6 +196,11 @@ export default function ReportsPage() { )} + {isAktiebolag && ( + + + + )} @@ -828,23 +840,32 @@ function VatDeclarationView() {

Utgående moms (försäljning)

+ {data.rutor.ruta05 > 0 && ( + + + + + )} @@ -975,7 +996,7 @@ function VatRutaRow({ - + diff --git a/app/(public)/dpa/page.tsx b/app/(public)/dpa/page.tsx new file mode 100644 index 00000000..a7d1a17a --- /dev/null +++ b/app/(public)/dpa/page.tsx @@ -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 ( +
+
+
+

+ Personuppgiftsbitradesavtal (DPA) +

+

+ Enligt GDPR Art. 28 | Senast uppdaterad: 2026-03-05 +

+
+ + + + 1. Roller + + +

+ Detta personuppgiftsbitradesavtal ("DPA") ingar mellan: +

+
    +
  • Personuppgiftsansvarig ("den Ansvarige"): Du som anvandare av Gnubok, + i egenskap av ansvarig for de personuppgifter du registrerar i tjansten + (kunder, leverantorer, anstallda m.fl.).
  • +
  • Personuppgiftsbitrade ("Bitradet"): Arcim, som tillhandahaller + Gnubok-tjansten och behandlar personuppgifter pa dina vagar.
  • +
+
+
+ + + + 2. Behandlingens syfte och omfattning + + +

Bitradet behandlar personuppgifter for foljande andamal:

+
    +
  • Tillhandahallande av bokforings- och redovisningstjanster
  • +
  • Lagring och arkivering av bokforingsmaterial
  • +
  • Fakturering och betalningshantering
  • +
  • Bankkontosynkronisering (PSD2)
  • +
  • AI-assisterad kategorisering och kvittohantering (efter separat samtycke)
  • +
+

Kategorier av registrerade vars uppgifter behandlas:

+
    +
  • Den Ansvariges kunder (namn, kontaktuppgifter, organisationsnummer)
  • +
  • Den Ansvariges leverantorer (namn, kontaktuppgifter, bankuppgifter)
  • +
  • Den Ansvarige sjalv (kontouppgifter, foretagsinformation)
  • +
+
+
+ + + + 3. Tekniska och organisatoriska atgarder + + +

Bitradet vidtar foljande atgarder for att skydda personuppgifterna:

+
    +
  • Kryptering: All data krypteras i transit (TLS 1.3) och i vila (AES-256)
  • +
  • Atkomstkontroll: Row Level Security (RLS) sakerstaller att varje anvandare + enbart kan komma at sina egna uppgifter
  • +
  • Autentisering: Sakra inloggningsmetoder (magic link, inga losenord lagrade)
  • +
  • Integritetskontroll: SHA-256 checksummor for alla dokument, med + regelbunden verifiering
  • +
  • Revisionslogg: Alla andringshandelser loggas automatiskt av databasen + (ej redigerbara)
  • +
  • Oforanderlig bokforing: Bokforda verifikationer kan inte andras eller + raderas (databasutlosare)
  • +
  • Sakerhetskopior: Kontinuerliga databaskopior med point-in-time-recovery
  • +
  • EU-lagring: All primar datalagring sker i EU (eu-central-1)
  • +
+
+
+ + + + 4. Underbitraden + + +

+ Bitradet anvander underbitraden for att tillhandahalla tjansten. En fullstandig + forteckning over underbitraden, inklusive syfte och geografisk plats, finns i + var{' '} + + integritetspolicy + . +

+

+ Bitradet kommer att informera den Ansvarige minst 30 dagar i forvag innan + en ny underbitrade anlitas, sa att den Ansvarige har mojlighet att invanda. +

+
+
+ + + + 5. Dataintrangsnotifiering + + +

+ Vid en personuppgiftsincident ska Bitradet utan ondodigt drojsmal, och senast + inom 72 timmar fran det att incidenten upptacktes, meddela den Ansvarige. + Meddelandet ska innehalla: +

+
    +
  • Typ av personuppgiftsincident
  • +
  • Kategorier och ungefirligt antal registrerade som berorts
  • +
  • Sannolika konsekvenser av incidenten
  • +
  • Atgarder som vidtagits eller foreslas for att hantera incidenten
  • +
+

+ Bitradet ska bistå den Ansvarige med den information som behovs for att den + Ansvarige ska kunna uppfylla sin anmalningsplikt till IMY (Integritetsskyddsmyndigheten). +

+
+
+ + + + 6. Revisionsratt + + +

+ 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. +

+

+ Revisioner ska ske med rimligt varsel (minst 30 dagar) och under ordinarie + kontorstider. Bitradet kan erbjuda alternativ i form av tredjepartsgranskningar + eller certifieringar. +

+
+
+ + + + 7. Radering vid avslut + + +

+ Vid uppsagning av tjansten ska Bitradet, enligt den Ansvariges val: +

+
    +
  • + Aterlamna: Exportera alla personuppgifter i maskinlasbart format + (SIE4, JSON, CSV) via tjansens exportfunktioner. +
  • +
  • + Radera: Radera alla personuppgifter inom 30 dagar fran + anvandarens begaran, med undantag for uppgifter som maste bevaras enligt lag. +
  • +
+

+ Undantag: 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. +

+
+
+ + + +

+ 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. +

+
+
+
+
+ ) +} diff --git a/app/(public)/privacy/page.tsx b/app/(public)/privacy/page.tsx new file mode 100644 index 00000000..efc856f6 --- /dev/null +++ b/app/(public)/privacy/page.tsx @@ -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 ( +
+
+
+

+ Integritetspolicy +

+

+ Senast uppdaterad: 2026-03-05 +

+
+ + + + 1. Personuppgiftsansvarig + + +

+ 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. +

+
+
+ + + + 2. Vilka uppgifter vi behandlar + + +

Vi behandlar foljande kategorier av personuppgifter:

+
    +
  • Kontouppgifter: E-postadress (for inloggning via magic link)
  • +
  • Foretagsuppgifter: Foretagsnamn, organisationsnummer, adress, kontaktuppgifter
  • +
  • Bokforingsdata: Verifikationer, fakturor, kvitton, transaktioner, kontoplaner
  • +
  • Bankdata: Kontosaldon och transaktioner (via PSD2-koppling)
  • +
  • Dokument: Uppladdade kvitton, fakturor och andra bokforingsunderlag
  • +
  • Tekniska uppgifter: IP-adress, enhetstyp, anvandningsstatistik
  • +
+
+
+ + + + 3. Rattslig grund (GDPR Art. 6) + + +
    +
  • + Avtal (Art. 6.1b): Behandling som ar nodvandig for att fullgora vara + tjanster enligt anvandaravtalet. +
  • +
  • + Rattslig forpliktelse (Art. 6.1c): Bokforingslagens (BFL) krav pa + 7 ars arkivering av raknenskapsmaterial. +
  • +
  • + Berattigat intresse (Art. 6.1f): Produktforbattringar, sakerhet och + bedrageriforbud. +
  • +
  • + Samtycke (Art. 6.1a): For AI-baserade funktioner som skickar data + till tredjepartstjanster (se separat samtycke vid aktivering). +
  • +
+
+
+ + + + 4. Underbitraden + + +

+ 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). +

+ +
+
+ 05 + Momspliktig försäljning + {formatAmount(data.rutor.ruta05)} kr
Summa utgående - {formatAmount(data.rutor.ruta05 + data.rutor.ruta06 + data.rutor.ruta07)} kr + {formatAmount(data.rutor.ruta10 + data.rutor.ruta11 + data.rutor.ruta12)} kr
{noVat ? '-' : `${formatAmount(amount)} kr`}
Underlag (ruta {parseInt(ruta) + 5})Underlag {formatAmount(baseAmount)} kr
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UnderbitradeSyftePlatsSkyddsmekanism
SupabaseDatabas, autentisering, fillagringEU (eu-central-1)EU-baserad lagring
VercelApplikationshostingGlobalt CDN (EU-regioner tillgangliga)EU Data Residency
Anthropic + Kvitto-OCR (receipt-ocr), transaktionskategorisering (ai-categorization), + AI-chattassistent (ai-chat) + USASCCs (standardavtalsklausuler)
OpenAI + Embedding-generering for likhetssokning (transaktionsmallar, kunskapsbas) + USASCCs (standardavtalsklausuler)
Enable BankingPSD2-bankkontouppkopplingEUEU-baserad
ResendTransaktionell e-postleveransUSASCCs (standardavtalsklausuler)
RecaptProduktanalys och anvanderfeedbackEUEU-baserad
+ + +

+ AI-funktioner (Anthropic, OpenAI) kraver separat samtycke fore aktivering. + Data skickas forst nar du aktivt godkanner anvandningen. +

+ + + + + + 5. Tredjelandsoverforing + + +

+ 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). +

+
+
+ + + + 6. Lagringstid + + +
    +
  • + Bokforingsmaterial: 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. +
  • +
  • + Kontouppgifter: Sa lange kontot ar aktivt, plus 30 dagar efter + begaran om radering (for att hantera pagaende bokforingsplikter). +
  • +
  • + Tekniska loggar: Maximalt 90 dagar. +
  • +
+
+
+ + + + 7. Dina rattigheter + + +

Du har foljande rattigheter enligt GDPR:

+
    +
  • Tillgang (Art. 15): Du kan begara en kopia av alla dina personuppgifter.
  • +
  • Rattelse (Art. 16): Du kan begara rattelse av felaktiga uppgifter.
  • +
  • Radering (Art. 17): Du kan begara radering, med undantag for uppgifter som + omfattas av lagstadgade arkiveringskrav (BFL 7 ar).
  • +
  • Begransning (Art. 18): Du kan begara begransning av behandlingen.
  • +
  • Dataportabilitet (Art. 20): Du kan exportera dina uppgifter i + maskinlasbart format (SIE4, JSON, CSV) via exportfunktionerna i appen.
  • +
  • Invandning (Art. 21): Du kan invanda mot behandling baserad pa + berattigat intresse.
  • +
+

+ For att utova dina rattigheter, kontakta oss pa adressen nedan. Vi besvarar alla + forfragar inom 30 dagar. +

+
+
+ + + + 8. Kontaktuppgifter + + +

+ For fragor om behandlingen av dina personuppgifter, kontakta oss: +

+
    +
  • Foretag: Arcim
  • +
  • E-post: privacy@gnubok.se
  • +
+

+ Du har aven ratt att lamna klagomal till Integritetsskyddsmyndigheten (IMY), + www.imy.se. +

+
+
+ + + ) +} diff --git a/app/api/ai-consent/__tests__/route.test.ts b/app/api/ai-consent/__tests__/route.test.ts new file mode 100644 index 00000000..620c9ca3 --- /dev/null +++ b/app/api/ai-consent/__tests__/route.test.ts @@ -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 }>( + 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') + }) +}) diff --git a/app/api/ai-consent/route.ts b/app/api/ai-consent/route.ts new file mode 100644 index 00000000..abed5871 --- /dev/null +++ b/app/api/ai-consent/route.ts @@ -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 = {} + 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 } }) +} diff --git a/app/api/audit-trail/__tests__/route.test.ts b/app/api/audit-trail/__tests__/route.test.ts new file mode 100644 index 00000000..2310a4c0 --- /dev/null +++ b/app/api/audit-trail/__tests__/route.test.ts @@ -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' }) + }) +}) diff --git a/app/api/audit-trail/route.ts b/app/api/audit-trail/route.ts new file mode 100644 index 00000000..90fa10bb --- /dev/null +++ b/app/api/audit-trail/route.ts @@ -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 } + ) + } +} diff --git a/app/api/extensions/ext/[...path]/route.ts b/app/api/extensions/ext/[...path]/route.ts index 5d543c72..08610f34 100644 --- a/app/api/extensions/ext/[...path]/route.ts +++ b/app/api/extensions/ext/[...path]/route.ts @@ -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 = {} diff --git a/app/api/reports/audit-trail/__tests__/route.test.ts b/app/api/reports/audit-trail/__tests__/route.test.ts new file mode 100644 index 00000000..fd661cf2 --- /dev/null +++ b/app/api/reports/audit-trail/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/api/reports/audit-trail/route.ts b/app/api/reports/audit-trail/route.ts new file mode 100644 index 00000000..e11dc47f --- /dev/null +++ b/app/api/reports/audit-trail/route.ts @@ -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 } + ) + } +} diff --git a/app/api/reports/full-archive/route.ts b/app/api/reports/full-archive/route.ts new file mode 100644 index 00000000..57f4d478 --- /dev/null +++ b/app/api/reports/full-archive/route.ts @@ -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 }) + } +} diff --git a/app/api/reports/ink2/route.ts b/app/api/reports/ink2/route.ts new file mode 100644 index 00000000..efcbcdc9 --- /dev/null +++ b/app/api/reports/ink2/route.ts @@ -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 } + ) + } +} diff --git a/components/extensions/AiConsentDialog.tsx b/components/extensions/AiConsentDialog.tsx new file mode 100644 index 00000000..da77a9c3 --- /dev/null +++ b/components/extensions/AiConsentDialog.tsx @@ -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 ( + + + + AI-samtycke kravs + + Denna funktion anvander AI-tjanster fran externa leverantorer. + Granska informationen nedan innan du fortsatter. + + + +
+
+

Leverantor

+

{disclosure.provider}

+
+ +
+

Data som skickas

+
    + {disclosure.dataTypes.map((dt) => ( +
  • {dt}
  • + ))} +
+
+ +
+

Syfte

+

{disclosure.purpose}

+
+ +

+ Las mer i var{' '} + + integritetspolicy + + . Du kan nar som helst aterkalla ditt samtycke i installningarna. +

+
+ + + + + +
+
+ ) +} diff --git a/components/onboarding/Step3TaxRegistration.tsx b/components/onboarding/Step3TaxRegistration.tsx index 36392d31..8027b8dd 100644 --- a/components/onboarding/Step3TaxRegistration.tsx +++ b/components/onboarding/Step3TaxRegistration.tsx @@ -584,8 +584,8 @@ export default function Step3TaxRegistration({ content={

Behöver jag momsregistrera mig?

-

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.

-

Om din omsättning överstiger 80 000 kr per år behöver du momsregistrera dig.

+

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.

+

Om din omsättning överstiger 120 000 kr per år behöver du momsregistrera dig.

} side="right" @@ -611,7 +611,7 @@ export default function Step3TaxRegistration({ Jag är momsregistrerad

- Obligatoriskt om din omsättning överstiger 80 000 kr per år. + Obligatoriskt om din omsättning överstiger 120 000 kr per år.

diff --git a/components/reports/INK2DeclarationView.tsx b/components/reports/INK2DeclarationView.tsx new file mode 100644 index 00000000..8ceb1ae5 --- /dev/null +++ b/components/reports/INK2DeclarationView.tsx @@ -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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( +
+ {/* Info card */} + + + INK2 (Aktiebolag) + + +
+ +

+ 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. +

+
+
+ + {data && ( + + )} +
+
+
+ + {error && ( + + + + {error} + + + )} + + {data && ( + <> + {/* Warnings */} + {data.warnings.length > 0 && ( + + +
+ +
+ {data.warnings.map((warning, i) => ( +

{warning}

+ ))} +
+
+
+
+ )} + + {/* Company info */} + + +
+ + {data.companyInfo.companyName} + + + {data.fiscalYear.name} + +
+ {data.companyInfo.orgNumber && ( +

+ Org.nr: {data.companyInfo.orgNumber} +

+ )} +
+
+ + {/* Assets section */} + + + Tillgångar + + + + + {INK2_ASSET_CODES.map((code) => ( + + ))} + + + + + + + +
Summa tillgångar + {formatCurrency(data.totals.totalAssets)} +
+
+
+ + {/* Equity & Liabilities section */} + + + Eget kapital och skulder + + + + + {INK2_EQUITY_LIABILITY_CODES.map((code) => ( + + ))} + + + + + + + +
Summa eget kapital och skulder + {formatCurrency(data.totals.totalEquityLiabilities)} +
+
+
+ + {/* Income Statement section */} + + + Resultaträkning + + + + + {INK2_INCOME_STATEMENT_CODES.map((code) => { + const isExpense = code !== '7310' && code !== '7370' && code !== '7380' + return ( + + ) + })} + + + + + + + + + + + +
Rörelseresultat= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatCurrency(data.totals.operatingResult)} +
Resultat efter finansiella poster= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatCurrency(data.totals.resultAfterFinancial)} +
+
+
+ + )} + + {!data && !loading && !error && ( + + + Klicka "Hämta INK2" för att generera deklarationsunderlaget. + + + )} +
+ ) +} + +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 ( + <> + accounts.length > 0 && setExpanded(!expanded)} + > + + {code} + {label} + {accounts.length > 0 && ( + + ({accounts.length} konton) + + )} + + + {isExpense && amount > 0 ? '-' : ''}{formatCurrency(Math.abs(amount))} + + + {expanded && accounts.length > 0 && ( + + + + + {accounts.map((acc) => ( + + + + + + ))} + +
+ + {acc.accountName} + {isExpense && acc.amount > 0 ? '-' : ''}{formatCurrency(Math.abs(acc.amount))} +
+ + + )} + + ) +} diff --git a/components/reports/VatCompositionChart.tsx b/components/reports/VatCompositionChart.tsx index 3b3e2c70..6a358cf5 100644 --- a/components/reports/VatCompositionChart.tsx +++ b/components/reports/VatCompositionChart.tsx @@ -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) diff --git a/extensions/general/ai-chat/chatbot/tools.ts b/extensions/general/ai-chat/chatbot/tools.ts index 590f3d40..cbeedc48 100644 --- a/extensions/general/ai-chat/chatbot/tools.ts +++ b/extensions/general/ai-chat/chatbot/tools.ts @@ -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, }) diff --git a/lib/extensions/__tests__/ai-consent.test.ts b/lib/extensions/__tests__/ai-consent.test.ts new file mode 100644 index 00000000..81bc6a88 --- /dev/null +++ b/lib/extensions/__tests__/ai-consent.test.ts @@ -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['supabase'] + let mockResult: ReturnType['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() + }) + }) +}) diff --git a/lib/extensions/ai-consent.ts b/lib/extensions/ai-consent.ts new file mode 100644 index 00000000..6842e8f4 --- /dev/null +++ b/lib/extensions/ai-consent.ts @@ -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 = { + '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 { + 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 { + 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 { + 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' } + ) +} diff --git a/lib/reports/__tests__/full-archive-export.test.ts b/lib/reports/__tests__/full-archive-export.test.ts new file mode 100644 index 00000000..70031e02 --- /dev/null +++ b/lib/reports/__tests__/full-archive-export.test.ts @@ -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['supabase'] + let enqueueMany: ReturnType['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') + }) +}) diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index cd3ddb33..47325085 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -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) diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts new file mode 100644 index 00000000..18b7fea8 --- /dev/null +++ b/lib/reports/full-archive-export.ts @@ -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 { + 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' }) +} diff --git a/lib/reports/ink2/__tests__/ink2-engine.test.ts b/lib/reports/ink2/__tests__/ink2-engine.test.ts new file mode 100644 index 00000000..d0878208 --- /dev/null +++ b/lib/reports/ink2/__tests__/ink2-engine.test.ts @@ -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') + }) + }) +}) diff --git a/lib/reports/ink2/__tests__/sru-generator.test.ts b/lib/reports/ink2/__tests__/sru-generator.test.ts new file mode 100644 index 00000000..06374b1a --- /dev/null +++ b/lib/reports/ink2/__tests__/sru-generator.test.ts @@ -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 { + 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') + }) + }) +}) diff --git a/lib/reports/ink2/ink2-engine.ts b/lib/reports/ink2/ink2-engine.ts new file mode 100644 index 00000000..0483a9bd --- /dev/null +++ b/lib/reports/ink2/ink2-engine.ts @@ -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 { + + // 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() + for (const acc of accounts) { + accountNameMap.set(acc.account_number, acc.account_name) + } + + // Calculate balances per account (debit - credit) + const accountBalances = new Map() + + 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 +} diff --git a/lib/reports/ink2/sru-generator.ts b/lib/reports/ink2/sru-generator.ts new file mode 100644 index 00000000..20abe82f --- /dev/null +++ b/lib/reports/ink2/sru-generator.ts @@ -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` +} diff --git a/lib/reports/ink2/types.ts b/lib/reports/ink2/types.ts new file mode 100644 index 00000000..c271ac7d --- /dev/null +++ b/lib/reports/ink2/types.ts @@ -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 + 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 = { + '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'] diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index 4c06b6c6..fc7e1cda 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -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 = { - '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 diff --git a/package-lock.json b/package-lock.json index d412e525..d3646a1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 92ec8335..b1f05266 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/types/index.ts b/types/index.ts index 0bd8cced..4bc48f52 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1550,17 +1550,17 @@ export const RECEIPT_STATUS_LABELS: Record = { // 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 = { - 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å', } // ============================================================