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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-05 14:20:47 +01:00
co-authored by Claude Opus 4.6
parent 50bf7b3b0f
commit 29240738fa
32 changed files with 3496 additions and 97 deletions
+35 -14
View File
@@ -9,6 +9,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Download, AlertCircle, ChevronDown, ChevronRight } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView'
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart'
import { VatCompositionChart } from '@/components/reports/VatCompositionChart'
@@ -60,6 +61,7 @@ export default function ReportsPage() {
}
const isEnskildFirma = entityType === 'enskild_firma'
const isAktiebolag = entityType === 'aktiebolag'
return (
<div className="space-y-6">
@@ -135,6 +137,11 @@ export default function ReportsPage() {
NE-bilaga
</TabsTrigger>
)}
{isAktiebolag && (
<TabsTrigger value="ink2-declaration" className="w-full justify-start">
INK2
</TabsTrigger>
)}
</TabsList>
</div>
@@ -189,6 +196,11 @@ export default function ReportsPage() {
<NEDeclarationView periodId={selectedPeriod} />
</TabsContent>
)}
{isAktiebolag && (
<TabsContent value="ink2-declaration">
<INK2DeclarationView periodId={selectedPeriod} />
</TabsContent>
)}
<TabsContent value="huvudbok">
<GeneralLedgerView periodId={selectedPeriod} />
</TabsContent>
@@ -828,23 +840,32 @@ function VatDeclarationView() {
<h4 className="font-semibold mb-3">Utgående moms (försäljning)</h4>
<table className="w-full text-sm">
<tbody>
{data.rutor.ruta05 > 0 && (
<tr className="border-b">
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">05</span>
Momspliktig försäljning
</td>
<td className="py-2 text-right">{formatAmount(data.rutor.ruta05)} kr</td>
</tr>
)}
<VatRutaRow
ruta="05"
label="Moms 25%"
amount={data.rutor.ruta05}
baseAmount={data.rutor.ruta10}
ruta="10"
label="Utgående moms 25%"
amount={data.rutor.ruta10}
baseAmount={data.breakdown.invoices.base25}
/>
<VatRutaRow
ruta="06"
label="Moms 12%"
amount={data.rutor.ruta06}
baseAmount={data.rutor.ruta11}
ruta="11"
label="Utgående moms 12%"
amount={data.rutor.ruta11}
baseAmount={data.breakdown.invoices.base12}
/>
<VatRutaRow
ruta="07"
label="Moms 6%"
amount={data.rutor.ruta07}
baseAmount={data.rutor.ruta12}
ruta="12"
label="Utgående moms 6%"
amount={data.rutor.ruta12}
baseAmount={data.breakdown.invoices.base6}
/>
<VatRutaRow
ruta="39"
@@ -865,7 +886,7 @@ function VatDeclarationView() {
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa utgående</td>
<td className="py-2 text-right">
{formatAmount(data.rutor.ruta05 + data.rutor.ruta06 + data.rutor.ruta07)} kr
{formatAmount(data.rutor.ruta10 + data.rutor.ruta11 + data.rutor.ruta12)} kr
</td>
</tr>
</tfoot>
@@ -975,7 +996,7 @@ function VatRutaRow({
<td className="py-2 text-right">{noVat ? '-' : `${formatAmount(amount)} kr`}</td>
</tr>
<tr className="text-muted-foreground">
<td className="py-1 pl-6 text-xs">Underlag (ruta {parseInt(ruta) + 5})</td>
<td className="py-1 pl-6 text-xs">Underlag</td>
<td className="py-1 text-right text-xs">{formatAmount(baseAmount)} kr</td>
</tr>
</>
+185
View File
@@ -0,0 +1,185 @@
import type { Metadata } from 'next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import Link from 'next/link'
export const metadata: Metadata = {
title: 'Personuppgiftsbitradesavtal - Gnubok',
}
export default function DPAPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white py-12 px-4">
<div className="max-w-3xl mx-auto space-y-6">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Personuppgiftsbitradesavtal (DPA)
</h1>
<p className="text-muted-foreground">
Enligt GDPR Art. 28 | Senast uppdaterad: 2026-03-05
</p>
</div>
<Card>
<CardHeader>
<CardTitle>1. Roller</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Detta personuppgiftsbitradesavtal (&quot;DPA&quot;) ingar mellan:
</p>
<ul>
<li><strong>Personuppgiftsansvarig (&quot;den Ansvarige&quot;):</strong> Du som anvandare av Gnubok,
i egenskap av ansvarig for de personuppgifter du registrerar i tjansten
(kunder, leverantorer, anstallda m.fl.).</li>
<li><strong>Personuppgiftsbitrade (&quot;Bitradet&quot;):</strong> Arcim, som tillhandahaller
Gnubok-tjansten och behandlar personuppgifter pa dina vagar.</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>2. Behandlingens syfte och omfattning</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Bitradet behandlar personuppgifter for foljande andamal:</p>
<ul>
<li>Tillhandahallande av bokforings- och redovisningstjanster</li>
<li>Lagring och arkivering av bokforingsmaterial</li>
<li>Fakturering och betalningshantering</li>
<li>Bankkontosynkronisering (PSD2)</li>
<li>AI-assisterad kategorisering och kvittohantering (efter separat samtycke)</li>
</ul>
<p>Kategorier av registrerade vars uppgifter behandlas:</p>
<ul>
<li>Den Ansvariges kunder (namn, kontaktuppgifter, organisationsnummer)</li>
<li>Den Ansvariges leverantorer (namn, kontaktuppgifter, bankuppgifter)</li>
<li>Den Ansvarige sjalv (kontouppgifter, foretagsinformation)</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>3. Tekniska och organisatoriska atgarder</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Bitradet vidtar foljande atgarder for att skydda personuppgifterna:</p>
<ul>
<li><strong>Kryptering:</strong> All data krypteras i transit (TLS 1.3) och i vila (AES-256)</li>
<li><strong>Atkomstkontroll:</strong> Row Level Security (RLS) sakerstaller att varje anvandare
enbart kan komma at sina egna uppgifter</li>
<li><strong>Autentisering:</strong> Sakra inloggningsmetoder (magic link, inga losenord lagrade)</li>
<li><strong>Integritetskontroll:</strong> SHA-256 checksummor for alla dokument, med
regelbunden verifiering</li>
<li><strong>Revisionslogg:</strong> Alla andringshandelser loggas automatiskt av databasen
(ej redigerbara)</li>
<li><strong>Oforanderlig bokforing:</strong> Bokforda verifikationer kan inte andras eller
raderas (databasutlosare)</li>
<li><strong>Sakerhetskopior:</strong> Kontinuerliga databaskopior med point-in-time-recovery</li>
<li><strong>EU-lagring:</strong> All primar datalagring sker i EU (eu-central-1)</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>4. Underbitraden</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Bitradet anvander underbitraden for att tillhandahalla tjansten. En fullstandig
forteckning over underbitraden, inklusive syfte och geografisk plats, finns i
var{' '}
<Link href="/privacy" className="text-primary underline underline-offset-4">
integritetspolicy
</Link>.
</p>
<p>
Bitradet kommer att informera den Ansvarige minst 30 dagar i forvag innan
en ny underbitrade anlitas, sa att den Ansvarige har mojlighet att invanda.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>5. Dataintrangsnotifiering</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Vid en personuppgiftsincident ska Bitradet utan ondodigt drojsmal, och senast
inom 72 timmar fran det att incidenten upptacktes, meddela den Ansvarige.
Meddelandet ska innehalla:
</p>
<ul>
<li>Typ av personuppgiftsincident</li>
<li>Kategorier och ungefirligt antal registrerade som berorts</li>
<li>Sannolika konsekvenser av incidenten</li>
<li>Atgarder som vidtagits eller foreslas for att hantera incidenten</li>
</ul>
<p>
Bitradet ska bistå den Ansvarige med den information som behovs for att den
Ansvarige ska kunna uppfylla sin anmalningsplikt till IMY (Integritetsskyddsmyndigheten).
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>6. Revisionsratt</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Den Ansvarige har ratt att, direkt eller genom en oberoende revisor, utfora
revisioner och inspektioner for att sakerst alla att Bitradet uppfyller sina
atagarder enligt detta avtal. Bitradet ska tillhandahalla all nodvandig
information och medverka till revisioner.
</p>
<p>
Revisioner ska ske med rimligt varsel (minst 30 dagar) och under ordinarie
kontorstider. Bitradet kan erbjuda alternativ i form av tredjepartsgranskningar
eller certifieringar.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>7. Radering vid avslut</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Vid uppsagning av tjansten ska Bitradet, enligt den Ansvariges val:
</p>
<ul>
<li>
<strong>Aterlamna:</strong> Exportera alla personuppgifter i maskinlasbart format
(SIE4, JSON, CSV) via tjansens exportfunktioner.
</li>
<li>
<strong>Radera:</strong> Radera alla personuppgifter inom 30 dagar fran
anvandarens begaran, med undantag for uppgifter som maste bevaras enligt lag.
</li>
</ul>
<p>
<strong>Undantag:</strong> Bokforingsmaterial som omfattas av Bokforingslagen (BFL)
7 kap. 2 § (7 ars arkiveringskrav) raderas forst nar lagringsfristen lopt ut.
Under denna period ar materialet skyddat mot obehorig atkomst och andring.
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground text-center">
Detta personuppgiftsbitradesavtal trader i kraft nar du skapar ett konto pa
Gnubok och galler sa lange du anvander tjansten. For fragor, kontakta oss
pa privacy@gnubok.se.
</p>
</CardContent>
</Card>
</div>
</div>
)
}
+237
View File
@@ -0,0 +1,237 @@
import type { Metadata } from 'next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export const metadata: Metadata = {
title: 'Integritetspolicy - Gnubok',
}
export default function PrivacyPolicyPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white py-12 px-4">
<div className="max-w-3xl mx-auto space-y-6">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Integritetspolicy
</h1>
<p className="text-muted-foreground">
Senast uppdaterad: 2026-03-05
</p>
</div>
<Card>
<CardHeader>
<CardTitle>1. Personuppgiftsansvarig</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Arcim (&quot;vi&quot;, &quot;oss&quot;) ar personuppgiftsansvarig for behandlingen av dina
personuppgifter i samband med anvandningen av Gnubok. Vi behandlar dina uppgifter i
enlighet med EU:s dataskyddsforordning (GDPR) och svensk dataskyddslagstiftning.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>2. Vilka uppgifter vi behandlar</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Vi behandlar foljande kategorier av personuppgifter:</p>
<ul>
<li><strong>Kontouppgifter:</strong> E-postadress (for inloggning via magic link)</li>
<li><strong>Foretagsuppgifter:</strong> Foretagsnamn, organisationsnummer, adress, kontaktuppgifter</li>
<li><strong>Bokforingsdata:</strong> Verifikationer, fakturor, kvitton, transaktioner, kontoplaner</li>
<li><strong>Bankdata:</strong> Kontosaldon och transaktioner (via PSD2-koppling)</li>
<li><strong>Dokument:</strong> Uppladdade kvitton, fakturor och andra bokforingsunderlag</li>
<li><strong>Tekniska uppgifter:</strong> IP-adress, enhetstyp, anvandningsstatistik</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>3. Rattslig grund (GDPR Art. 6)</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<ul>
<li>
<strong>Avtal (Art. 6.1b):</strong> Behandling som ar nodvandig for att fullgora vara
tjanster enligt anvandaravtalet.
</li>
<li>
<strong>Rattslig forpliktelse (Art. 6.1c):</strong> Bokforingslagens (BFL) krav pa
7 ars arkivering av raknenskapsmaterial.
</li>
<li>
<strong>Berattigat intresse (Art. 6.1f):</strong> Produktforbattringar, sakerhet och
bedrageriforbud.
</li>
<li>
<strong>Samtycke (Art. 6.1a):</strong> For AI-baserade funktioner som skickar data
till tredjepartstjanster (se separat samtycke vid aktivering).
</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>4. Underbitraden</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Vi anvander foljande underbitraden for att tillhandahalla tjansten. Uppgifterna nedan anger
vilka uppgifter som delas med respektive underbitrade, syftet samt var behandlingen sker
(GDPR Art. 13).
</p>
<div className="overflow-x-auto mt-4">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 pr-4 font-semibold">Underbitrade</th>
<th className="text-left py-2 pr-4 font-semibold">Syfte</th>
<th className="text-left py-2 pr-4 font-semibold">Plats</th>
<th className="text-left py-2 font-semibold">Skyddsmekanism</th>
</tr>
</thead>
<tbody>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Supabase</td>
<td className="py-2 pr-4">Databas, autentisering, fillagring</td>
<td className="py-2 pr-4">EU (eu-central-1)</td>
<td className="py-2">EU-baserad lagring</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Vercel</td>
<td className="py-2 pr-4">Applikationshosting</td>
<td className="py-2 pr-4">Globalt CDN (EU-regioner tillgangliga)</td>
<td className="py-2">EU Data Residency</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Anthropic</td>
<td className="py-2 pr-4">
Kvitto-OCR (receipt-ocr), transaktionskategorisering (ai-categorization),
AI-chattassistent (ai-chat)
</td>
<td className="py-2 pr-4">USA</td>
<td className="py-2">SCCs (standardavtalsklausuler)</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">OpenAI</td>
<td className="py-2 pr-4">
Embedding-generering for likhetssokning (transaktionsmallar, kunskapsbas)
</td>
<td className="py-2 pr-4">USA</td>
<td className="py-2">SCCs (standardavtalsklausuler)</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Enable Banking</td>
<td className="py-2 pr-4">PSD2-bankkontouppkoppling</td>
<td className="py-2 pr-4">EU</td>
<td className="py-2">EU-baserad</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Resend</td>
<td className="py-2 pr-4">Transaktionell e-postleverans</td>
<td className="py-2 pr-4">USA</td>
<td className="py-2">SCCs (standardavtalsklausuler)</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Recapt</td>
<td className="py-2 pr-4">Produktanalys och anvanderfeedback</td>
<td className="py-2 pr-4">EU</td>
<td className="py-2">EU-baserad</td>
</tr>
</tbody>
</table>
</div>
<p className="mt-4 text-sm text-muted-foreground">
AI-funktioner (Anthropic, OpenAI) kraver separat samtycke fore aktivering.
Data skickas forst nar du aktivt godkanner anvandningen.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>5. Tredjelandsoverforing</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Vissa underbitraden ar baserade i USA. For dessa overforing anvands EU-kommissionens
standardavtalsklausuler (SCCs) som skyddsmekanism i enlighet med GDPR kapitel V.
All primaer datalagring (databas, filer) sker inom EU via Supabase (eu-central-1).
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>6. Lagringstid</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<ul>
<li>
<strong>Bokforingsmaterial:</strong> 7 ar fran rakenskapsarets slut, i enlighet
med Bokforingslagen (BFL) 7 kap. 2 §. Systemet hindrar radering av material
kopplat till bokforda verifikationer under denna period.
</li>
<li>
<strong>Kontouppgifter:</strong> Sa lange kontot ar aktivt, plus 30 dagar efter
begaran om radering (for att hantera pagaende bokforingsplikter).
</li>
<li>
<strong>Tekniska loggar:</strong> Maximalt 90 dagar.
</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>7. Dina rattigheter</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Du har foljande rattigheter enligt GDPR:</p>
<ul>
<li><strong>Tillgang (Art. 15):</strong> Du kan begara en kopia av alla dina personuppgifter.</li>
<li><strong>Rattelse (Art. 16):</strong> Du kan begara rattelse av felaktiga uppgifter.</li>
<li><strong>Radering (Art. 17):</strong> Du kan begara radering, med undantag for uppgifter som
omfattas av lagstadgade arkiveringskrav (BFL 7 ar).</li>
<li><strong>Begransning (Art. 18):</strong> Du kan begara begransning av behandlingen.</li>
<li><strong>Dataportabilitet (Art. 20):</strong> Du kan exportera dina uppgifter i
maskinlasbart format (SIE4, JSON, CSV) via exportfunktionerna i appen.</li>
<li><strong>Invandning (Art. 21):</strong> Du kan invanda mot behandling baserad pa
berattigat intresse.</li>
</ul>
<p>
For att utova dina rattigheter, kontakta oss pa adressen nedan. Vi besvarar alla
forfragar inom 30 dagar.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>8. Kontaktuppgifter</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
For fragor om behandlingen av dina personuppgifter, kontakta oss:
</p>
<ul>
<li><strong>Foretag:</strong> Arcim</li>
<li><strong>E-post:</strong> privacy@gnubok.se</li>
</ul>
<p>
Du har aven ratt att lamna klagomal till Integritetsskyddsmyndigheten (IMY),
www.imy.se.
</p>
</CardContent>
</Card>
</div>
</div>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/extensions/ai-consent', () => ({
AI_EXTENSIONS: ['receipt-ocr', 'ai-categorization', 'ai-chat'],
hasAiConsent: vi.fn(),
grantAiConsent: vi.fn(),
revokeAiConsent: vi.fn(),
isAiExtension: vi.fn((id: string) =>
['receipt-ocr', 'ai-categorization', 'ai-chat'].includes(id)
),
}))
import { createClient } from '@/lib/supabase/server'
import { hasAiConsent, grantAiConsent, revokeAiConsent } from '@/lib/extensions/ai-consent'
import { GET, POST, DELETE } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockHasAiConsent = vi.mocked(hasAiConsent)
const mockGrantAiConsent = vi.mocked(grantAiConsent)
const mockRevokeAiConsent = vi.mocked(revokeAiConsent)
function mockAuth(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
}),
},
} as any)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/ai-consent', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const { status } = await parseJsonResponse(await GET())
expect(status).toBe(401)
})
it('returns consent status for all AI extensions', async () => {
mockAuth('user-1')
mockHasAiConsent
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
const { status, body } = await parseJsonResponse<{ data: Record<string, boolean> }>(
await GET()
)
expect(status).toBe(200)
expect(body.data).toEqual({
'receipt-ocr': true,
'ai-categorization': false,
'ai-chat': true,
})
})
})
describe('POST /api/ai-consent', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/ai-consent', {
method: 'POST',
body: { extension_id: 'receipt-ocr' },
})
const { status } = await parseJsonResponse(await POST(req))
expect(status).toBe(401)
})
it('grants consent for valid AI extension', async () => {
mockAuth('user-1')
mockGrantAiConsent.mockResolvedValue(undefined)
const req = createMockRequest('/api/ai-consent', {
method: 'POST',
body: { extension_id: 'receipt-ocr' },
})
const { status, body } = await parseJsonResponse<{ data: { consented: boolean } }>(
await POST(req)
)
expect(status).toBe(200)
expect(body.data.consented).toBe(true)
expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'receipt-ocr')
})
it('returns 400 for non-AI extension', async () => {
mockAuth('user-1')
const req = createMockRequest('/api/ai-consent', {
method: 'POST',
body: { extension_id: 'enable-banking' },
})
const { status } = await parseJsonResponse(await POST(req))
expect(status).toBe(400)
})
})
describe('DELETE /api/ai-consent', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/ai-consent', {
method: 'DELETE',
body: { extension_id: 'ai-chat' },
})
const { status } = await parseJsonResponse(await DELETE(req))
expect(status).toBe(401)
})
it('revokes consent for valid AI extension', async () => {
mockAuth('user-1')
mockRevokeAiConsent.mockResolvedValue(undefined)
const req = createMockRequest('/api/ai-consent', {
method: 'DELETE',
body: { extension_id: 'ai-chat' },
})
const { status, body } = await parseJsonResponse<{ data: { consented: boolean } }>(
await DELETE(req)
)
expect(status).toBe(200)
expect(body.data.consented).toBe(false)
expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'ai-chat')
})
})
+69
View File
@@ -0,0 +1,69 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import {
AI_EXTENSIONS,
hasAiConsent,
grantAiConsent,
revokeAiConsent,
isAiExtension,
} from '@/lib/extensions/ai-consent'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const statuses: Record<string, boolean> = {}
for (const ext of AI_EXTENSIONS) {
statuses[ext] = await hasAiConsent(supabase, user.id, ext)
}
return NextResponse.json({ data: statuses })
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { extension_id } = body
if (!extension_id || !isAiExtension(extension_id)) {
return NextResponse.json(
{ error: 'Invalid or non-AI extension_id' },
{ status: 400 }
)
}
await grantAiConsent(supabase, user.id, extension_id)
return NextResponse.json({ data: { consented: true } })
}
export async function DELETE(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { extension_id } = body
if (!extension_id || !isAiExtension(extension_id)) {
return NextResponse.json(
{ error: 'Invalid or non-AI extension_id' },
{ status: 400 }
)
}
await revokeAiConsent(supabase, user.id, extension_id)
return NextResponse.json({ data: { consented: false } })
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn(),
}))
import { createClient } from '@/lib/supabase/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { GET } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockGetAuditLog = vi.mocked(getAuditLog)
function mockAuth(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
}),
},
} as any)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/audit-trail', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/audit-trail')
const { status, body } = await parseJsonResponse(await GET(req))
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns audit log with data and count', async () => {
mockAuth('user-1')
const entries = [
{ id: '1', action: 'INSERT', table_name: 'journal_entries', created_at: '2024-01-01T00:00:00Z' },
{ id: '2', action: 'COMMIT', table_name: 'journal_entries', created_at: '2024-01-02T00:00:00Z' },
]
mockGetAuditLog.mockResolvedValue({ data: entries as any, count: 2 })
const req = createMockRequest('/api/audit-trail')
const { status, body } = await parseJsonResponse<{ data: any[]; count: number }>(await GET(req))
expect(status).toBe(200)
expect(body.data).toHaveLength(2)
expect(body.count).toBe(2)
expect(mockGetAuditLog).toHaveBeenCalledWith(
expect.anything(),
'user-1',
expect.objectContaining({})
)
})
it('passes query param filters to getAuditLog', async () => {
mockAuth('user-1')
mockGetAuditLog.mockResolvedValue({ data: [], count: 0 })
const req = createMockRequest('/api/audit-trail', {
searchParams: {
action: 'INSERT',
table_name: 'journal_entries',
record_id: 'rec-1',
from_date: '2024-01-01',
to_date: '2024-12-31',
page: '2',
page_size: '25',
},
})
await GET(req)
expect(mockGetAuditLog).toHaveBeenCalledWith(
expect.anything(),
'user-1',
{
action: 'INSERT',
table_name: 'journal_entries',
record_id: 'rec-1',
from_date: '2024-01-01',
to_date: '2024-12-31',
page: 2,
pageSize: 25,
}
)
})
it('returns 500 on service error', async () => {
mockAuth('user-1')
mockGetAuditLog.mockRejectedValue(new Error('DB error'))
const req = createMockRequest('/api/audit-trail')
const { status, body } = await parseJsonResponse(await GET(req))
expect(status).toBe(500)
expect(body).toEqual({ error: 'DB error' })
})
})
+35
View File
@@ -0,0 +1,35 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import type { AuditAction } from '@/types'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const filters = {
action: (searchParams.get('action') as AuditAction) || undefined,
table_name: searchParams.get('table_name') || undefined,
record_id: searchParams.get('record_id') || undefined,
from_date: searchParams.get('from_date') || undefined,
to_date: searchParams.get('to_date') || undefined,
page: searchParams.has('page') ? Number(searchParams.get('page')) : undefined,
pageSize: searchParams.has('page_size') ? Number(searchParams.get('page_size')) : undefined,
}
try {
const result = await getAuditLog(supabase, user.id, filters)
return NextResponse.json({ data: result.data, count: result.count })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to fetch audit log' },
{ status: 500 }
)
}
}
+12
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { extensionRegistry } from '@/lib/extensions/registry'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { isExtensionEnabled } from '@/lib/extensions/toggle-check'
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
import type { ApiRouteDefinition } from '@/lib/extensions/types'
ensureInitialized()
@@ -82,6 +83,17 @@ async function handleRequest(
return NextResponse.json({ error: 'Extension is disabled' }, { status: 403 })
}
// AI consent check
if (isAiExtension(extensionId)) {
const consented = await hasAiConsent(supabase, user.id, extensionId)
if (!consented) {
return NextResponse.json(
{ error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' },
{ status: 403 }
)
}
}
// Find matching route (supports :param patterns)
let matchedRoute: ApiRouteDefinition | null = null
let extractedParams: Record<string, string> = {}
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest } from '@/tests/helpers'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn(),
}))
import { createClient } from '@/lib/supabase/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { GET } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockGetAuditLog = vi.mocked(getAuditLog)
function mockAuth(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
}),
},
} as any)
}
const sampleEntries = [
{
id: '1',
user_id: 'user-1',
action: 'INSERT' as const,
table_name: 'journal_entries',
record_id: 'rec-1',
actor_id: null,
old_state: null,
new_state: { description: 'Test entry' },
description: 'Created journal entry',
created_at: '2024-06-15T10:00:00Z',
},
{
id: '2',
user_id: 'user-1',
action: 'COMMIT' as const,
table_name: 'journal_entries',
record_id: 'rec-1',
actor_id: null,
old_state: { status: 'draft' },
new_state: { status: 'posted' },
description: 'Committed journal entry',
created_at: '2024-06-15T10:01:00Z',
},
]
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/reports/audit-trail', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/reports/audit-trail')
const res = await GET(req)
expect(res.status).toBe(401)
})
it('returns CSV format with correct headers', async () => {
mockAuth('user-1')
mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 })
const req = createMockRequest('/api/reports/audit-trail', {
searchParams: { format: 'csv' },
})
const res = await GET(req)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('text/csv; charset=utf-8')
expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="audit-trail.csv"')
const text = await res.text()
const lines = text.split('\n')
expect(lines[0]).toBe('timestamp,action,table_name,record_id,description,old_state,new_state')
expect(lines).toHaveLength(3) // header + 2 entries
expect(lines[1]).toContain('INSERT')
expect(lines[1]).toContain('journal_entries')
})
it('returns JSON format as downloadable file', async () => {
mockAuth('user-1')
mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 })
const req = createMockRequest('/api/reports/audit-trail', {
searchParams: { format: 'json' },
})
const res = await GET(req)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/json')
expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="audit-trail.json"')
const body = await res.json()
expect(body.data).toHaveLength(2)
expect(body.count).toBe(2)
})
it('paginates through all entries', async () => {
mockAuth('user-1')
// First call returns 500 entries (full page), second returns 100 (last page)
const bigPage = Array.from({ length: 500 }, (_, i) => ({
...sampleEntries[0],
id: `entry-${i}`,
}))
const lastPage = Array.from({ length: 100 }, (_, i) => ({
...sampleEntries[0],
id: `entry-${500 + i}`,
}))
mockGetAuditLog
.mockResolvedValueOnce({ data: bigPage as any, count: 600 })
.mockResolvedValueOnce({ data: lastPage as any, count: 600 })
const req = createMockRequest('/api/reports/audit-trail', {
searchParams: { format: 'json' },
})
const res = await GET(req)
const body = await res.json()
expect(body.data).toHaveLength(600)
expect(mockGetAuditLog).toHaveBeenCalledTimes(2)
})
})
+94
View File
@@ -0,0 +1,94 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import type { AuditLogEntry, AuditAction } from '@/types'
const CSV_HEADERS = 'timestamp,action,table_name,record_id,description,old_state,new_state'
function escapeCSV(value: string | null | undefined): string {
if (value == null) return ''
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
function entryToCSVRow(entry: AuditLogEntry): string {
return [
escapeCSV(entry.created_at),
escapeCSV(entry.action),
escapeCSV(entry.table_name),
escapeCSV(entry.record_id),
escapeCSV(entry.description),
escapeCSV(entry.old_state ? JSON.stringify(entry.old_state) : null),
escapeCSV(entry.new_state ? JSON.stringify(entry.new_state) : null),
].join(',')
}
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const format = searchParams.get('format') || 'json'
const filters = {
action: (searchParams.get('action') as AuditAction) || undefined,
table_name: searchParams.get('table_name') || undefined,
record_id: searchParams.get('record_id') || undefined,
from_date: searchParams.get('from_date') || undefined,
to_date: searchParams.get('to_date') || undefined,
}
try {
// Paginate through all matching entries
const allEntries: AuditLogEntry[] = []
let page = 1
const pageSize = 500
while (true) {
const result = await getAuditLog(supabase, user.id, {
...filters,
page,
pageSize,
})
allEntries.push(...result.data)
if (allEntries.length >= result.count || result.data.length < pageSize) {
break
}
page++
}
if (format === 'csv') {
const csvRows = [CSV_HEADERS, ...allEntries.map(entryToCSVRow)]
const csvContent = csvRows.join('\n')
return new NextResponse(csvContent, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="audit-trail.csv"',
},
})
}
// Default: JSON
return new NextResponse(JSON.stringify({ data: allEntries, count: allEntries.length }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Content-Disposition': 'attachment; filename="audit-trail.json"',
},
})
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate audit trail report' },
{ status: 500 }
)
}
}
+38
View File
@@ -0,0 +1,38 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateFullArchive } from '@/lib/reports/full-archive-export'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
if (!periodId) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
try {
const zipBuffer = await generateFullArchive(supabase, user.id, {
period_id: periodId,
include_documents: searchParams.get('include_documents') !== 'false',
})
return new NextResponse(zipBuffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="arkiv_${periodId}.zip"`,
},
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to generate archive'
const status = message.includes('not found') ? 404 : 500
return NextResponse.json({ error: message }, { status })
}
}
+63
View File
@@ -0,0 +1,63 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateINK2Declaration } from '@/lib/reports/ink2/ink2-engine'
import {
generateSRUFile,
sruFileToString,
getSRUFilename,
} from '@/lib/reports/ink2/sru-generator'
/**
* GET /api/reports/ink2
*
* Generate INK2 declaration for aktiebolag.
*
* Query parameters:
* - period_id: Fiscal period ID (required)
* - format: 'json' (default) or 'sru' for SRU file download
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const format = searchParams.get('format') || 'json'
if (!periodId) {
return NextResponse.json(
{ error: 'period_id is required' },
{ status: 400 }
)
}
try {
const declaration = await generateINK2Declaration(supabase, user.id, periodId)
if (format === 'sru') {
const sruFile = generateSRUFile(declaration)
const sruContent = sruFileToString(sruFile)
const filename = getSRUFilename(declaration)
return new NextResponse(sruContent, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
}
return NextResponse.json({ data: declaration })
} catch (err) {
console.error('Error generating INK2 declaration:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate INK2 declaration' },
{ status: 500 }
)
}
}