diff --git a/CLAUDE.md b/CLAUDE.md index 2d1570eb..6bc43ed6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ The `VatDeclarationRutor` type maps to the Swedish tax authority's momsdeklarati - **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 +- **Ruta 49**: Moms att betala/återfå = (ruta 10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - ruta 48 `VatDeclaration.breakdown.invoices` also includes `base25`/`base12`/`base6` for per-rate revenue breakdown in the UI. diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx index af8abbe0..68fcb00e 100644 --- a/app/(dashboard)/customers/page.tsx +++ b/app/(dashboard)/customers/page.tsx @@ -8,8 +8,7 @@ import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' -import { Plus, Search, Users, MapPin, Mail, Hash } from 'lucide-react' -import { cn } from '@/lib/utils' +import { Plus, Search, Users } from 'lucide-react' import CustomerForm from '@/components/customers/CustomerForm' import { EmptyCustomers } from '@/components/ui/empty-state' import Link from 'next/link' @@ -22,13 +21,6 @@ const customerTypeLabels: Record = { non_eu_business: 'Utanför EU', } -const customerTypeColors: Record = { - individual: 'bg-blue-50 text-blue-700 border-blue-200', - swedish_business: 'bg-amber-50 text-amber-700 border-amber-200', - eu_business: 'bg-emerald-50 text-emerald-700 border-emerald-200', - non_eu_business: 'bg-violet-50 text-violet-700 border-violet-200', -} - function getInitials(name: string): string { return name .split(' ') @@ -187,26 +179,19 @@ export default function CustomersPage() {
{customer.name} - + {customerTypeLabels[customer.customer_type]} - +
-
+
{customer.email && ( -
- - {customer.email} -
+

{customer.email}

)} {customer.org_number && (
- {customer.org_number} {customer.vat_number_validated && ( Verifierad @@ -214,10 +199,7 @@ export default function CustomersPage() {
)} {customer.city && ( -
- - {customer.city}, {customer.country} -
+

{customer.city}, {customer.country}

)}
diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index ad2ad852..f5356b70 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -19,11 +19,7 @@ import { FileText, Download, XCircle, - Clock, - Building, Mail, - Phone, - MapPin, ReceiptText, ExternalLink, Bell, @@ -43,14 +39,14 @@ import { } from '@/components/ui/dialog' import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types' -const statusConfig: Record = { - draft: { label: 'Utkast', variant: 'secondary', icon: FileText }, - sent: { label: 'Skickad', variant: 'default', icon: Send }, - paid: { label: 'Betald', variant: 'success', icon: CheckCircle }, - partially_paid: { label: 'Delbetalad', variant: 'warning', icon: Clock }, - overdue: { label: 'Förfallen', variant: 'destructive', icon: Clock }, - cancelled: { label: 'Makulerad', variant: 'secondary', icon: XCircle }, - credited: { label: 'Krediterad', variant: 'secondary', icon: XCircle }, +const statusConfig: Record = { + draft: { label: 'Utkast', variant: 'secondary' }, + sent: { label: 'Skickad', variant: 'default' }, + paid: { label: 'Betald', variant: 'success' }, + partially_paid: { label: 'Delbetalad', variant: 'warning' }, + overdue: { label: 'Förfallen', variant: 'destructive' }, + cancelled: { label: 'Makulerad', variant: 'secondary' }, + credited: { label: 'Krediterad', variant: 'secondary' }, } const reminderLevelLabels: Record<1 | 2 | 3, string> = { @@ -346,7 +342,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st } const status = statusConfig[invoice.status] - const StatusIcon = status.icon const customer = invoice.customer const customerHasEmail = !!customer.email const docType = ((invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice') as InvoiceDocumentType @@ -371,7 +366,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st Följesedel )} - {status.label}
@@ -436,10 +430,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {/* Customer info */} - - - Kund - + Kund
@@ -450,31 +441,22 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {customer.vat_number && (

VAT: {customer.vat_number}

)} -
+
{customer.email && ( -
- - {customer.email} -
+ {customer.email} )} {customer.phone && ( -
- - {customer.phone} -
+ {customer.phone} )}
{(customer.address_line1 || customer.city) && ( -
- -
- {customer.address_line1 &&

{customer.address_line1}

} - {customer.address_line2 &&

{customer.address_line2}

} -

- {customer.postal_code} {customer.city} - {customer.country !== 'SE' && `, ${customer.country}`} -

-
+
+ {customer.address_line1 &&

{customer.address_line1}

} + {customer.address_line2 &&

{customer.address_line2}

} +

+ {customer.postal_code} {customer.city} + {customer.country !== 'SE' && `, ${customer.country}`} +

)}
diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 00fd8359..37ed8eb3 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -12,18 +12,18 @@ import { PageHeader } from '@/components/ui/page-header' import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' import { cn } from '@/lib/utils' -import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, FileQuestion, Truck } from 'lucide-react' +import { Plus, Search, Receipt } from 'lucide-react' import { EmptyInvoices } from '@/components/ui/empty-state' import type { Invoice, InvoiceStatus } from '@/types' -const statusConfig: Record = { - draft: { label: 'Utkast', variant: 'secondary', icon: FileText, borderColor: 'border-muted-foreground/30' }, - sent: { label: 'Skickad', variant: 'default', icon: Send, borderColor: 'border-warning/50' }, - paid: { label: 'Betald', variant: 'success', icon: CheckCircle, borderColor: 'border-success/50' }, - partially_paid: { label: 'Delbetalad', variant: 'warning', icon: Clock, borderColor: 'border-warning/50' }, - overdue: { label: 'Förfallen', variant: 'destructive', icon: Clock, borderColor: 'border-destructive/50' }, - cancelled: { label: 'Makulerad', variant: 'secondary', icon: XCircle, borderColor: 'border-muted-foreground/30' }, - credited: { label: 'Krediterad', variant: 'secondary', icon: XCircle, borderColor: 'border-muted-foreground/30' }, +const statusConfig: Record = { + draft: { label: 'Utkast', variant: 'secondary', borderColor: 'border-muted-foreground/30' }, + sent: { label: 'Skickad', variant: 'default', borderColor: 'border-warning/50' }, + paid: { label: 'Betald', variant: 'success', borderColor: 'border-success/50' }, + partially_paid: { label: 'Delbetalad', variant: 'warning', borderColor: 'border-warning/50' }, + overdue: { label: 'Förfallen', variant: 'destructive', borderColor: 'border-destructive/50' }, + cancelled: { label: 'Makulerad', variant: 'secondary', borderColor: 'border-muted-foreground/30' }, + credited: { label: 'Krediterad', variant: 'secondary', borderColor: 'border-muted-foreground/30' }, } function getRelativeTimeLabel(dueDateStr: string, status: InvoiceStatus): { text: string; color: string } | null { @@ -126,12 +126,9 @@ export default function InvoicesPage() { {[1, 2, 3].map((i) => ( -
-
-
-
-
-
+
+
+
@@ -141,48 +138,27 @@ export default function InvoicesPage() { <> -
-
- -
-
-

Totalt antal

-

{invoices.length}

-
+

Totalt antal

+

{invoices.length}

+ + + + +

Obetalda

+
+

{stats.unpaid}

+ {stats.overdue > 0 && ( + + {stats.overdue} förfallna + + )}
-
-
- -
-
-

Obetalda

-
-

{stats.unpaid}

- {stats.overdue > 0 && ( - - {stats.overdue} förfallna - - )} -
-
-
-
-
- - -
-
- -
-
-

Att få in

-

{formatCurrency(stats.unpaidAmount)}

-
-
+

Att få in

+

{formatCurrency(stats.unpaidAmount)}

@@ -262,7 +238,6 @@ export default function InvoicesPage() { const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice' const isProforma = docType === 'proforma' const isDeliveryNote = docType === 'delivery_note' - const StatusIcon = isCreditNote ? ReceiptText : isProforma ? FileQuestion : isDeliveryNote ? Truck : status.icon const relativeTime = invoice.due_date ? getRelativeTimeLabel(invoice.due_date, invoice.status) : null return ( @@ -272,11 +247,7 @@ export default function InvoicesPage() { invoice.status === 'overdue' && 'ring-1 ring-destructive/20' )}> -
-
- -
-
+

{invoice.invoice_number}

@@ -316,7 +287,6 @@ export default function InvoicesPage() { {formatCurrency(Number(invoice.total_sek))}

)} -
diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 903da628..de71e3b2 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -14,6 +14,7 @@ import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView' import { BankReconciliationView } from '@/components/reports/BankReconciliationView' import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart' import { VatCompositionChart } from '@/components/reports/VatCompositionChart' +import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel' import { IncomeExpenseChart } from '@/components/reports/IncomeExpenseChart' import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart' import type { @@ -1211,6 +1212,14 @@ function VatDeclarationView() { )} + {/* Skatteverket integration panel */} + + {!data && !loading && !error && ( diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx index 07d256e5..5733bdae 100644 --- a/app/(dashboard)/supplier-invoices/page.tsx +++ b/app/(dashboard)/supplier-invoices/page.tsx @@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' -import { Plus, FileInput, AlertCircle, Clock } from 'lucide-react' +import { Plus, FileInput } from 'lucide-react' import Link from 'next/link' import type { SupplierInvoice } from '@/types' @@ -13,14 +13,14 @@ function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } -const statusColors: Record = { - registered: 'bg-blue-100 text-blue-800', - approved: 'bg-yellow-100 text-yellow-800', - paid: 'bg-success/10 text-success', - partially_paid: 'bg-orange-100 text-orange-800', - overdue: 'bg-destructive/10 text-destructive', - disputed: 'bg-purple-100 text-purple-800', - credited: 'bg-gray-100 text-gray-800', +const statusVariants: Record = { + registered: 'secondary', + approved: 'default', + paid: 'success', + partially_paid: 'warning', + overdue: 'destructive', + disputed: 'warning', + credited: 'secondary', } const statusLabels: Record = { @@ -106,8 +106,7 @@ export default function SupplierInvoicesPage() { <> - - + Totalt obetalt @@ -120,8 +119,7 @@ export default function SupplierInvoicesPage() { - - + Förfallet @@ -132,8 +130,7 @@ export default function SupplierInvoicesPage() { - - + Antal fakturor @@ -230,7 +227,7 @@ export default function SupplierInvoicesPage() { {formatAmount(inv.total)} {formatAmount(inv.remaining_amount)} - + {statusLabels[inv.status] || inv.status} diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 83726d1e..0f97c3a6 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -34,6 +34,10 @@ vi.mock('@/lib/bookkeeping/mapping-engine', () => ({ saveUserMappingRule: (...args: unknown[]) => mockSaveUserMappingRule(...args), })) +vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ + upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined), +})) + import { POST } from '../route' describe('POST /api/transactions/[id]/categorize', () => { diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 7edf3a0b..ecc37449 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -6,6 +6,7 @@ import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mappi import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine' +import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' import { validateBody } from '@/lib/api/validate' import { CategorizeTransactionSchema } from '@/lib/api/schemas' import type { Transaction, TransactionCategory, EntityType } from '@/types' @@ -286,6 +287,15 @@ export async function POST( } } + // Upsert counterparty template for future auto-matching + try { + await upsertCounterpartyTemplate( + supabase, user.id, transaction as Transaction, mappingResult, 'user_approved' + ) + } catch { + // Non-critical + } + // Link receipt document to journal entry if both exist if (journalEntryId && transaction.receipt_id) { try { diff --git a/components/invoices/PaymentBookingDialog.tsx b/components/invoices/PaymentBookingDialog.tsx index e70e0c74..f4f8d06b 100644 --- a/components/invoices/PaymentBookingDialog.tsx +++ b/components/invoices/PaymentBookingDialog.tsx @@ -18,7 +18,7 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { proposePaymentLines } from '@/lib/bookkeeping/propose-payment-lines' import { formatCurrency } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' -import { Plus, Trash2, Loader2, CheckCircle2, AlertTriangle } from 'lucide-react' +import { Plus, Trash2, Loader2 } from 'lucide-react' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' import type { Invoice, InvoiceItem, Customer, BASAccount, EntityType } from '@/types' @@ -356,13 +356,11 @@ export default function PaymentBookingDialog({
{isBalanced ? ( - - + Debet = Kredit ) : ( - - + Obalanserad ({formatCurrency(Math.abs(totalDebit - totalCredit))}) )} diff --git a/components/reports/SkatteverketPanel.tsx b/components/reports/SkatteverketPanel.tsx new file mode 100644 index 00000000..eb945f77 --- /dev/null +++ b/components/reports/SkatteverketPanel.tsx @@ -0,0 +1,527 @@ +'use client' + +import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' +import React, { useState, useEffect, useCallback } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + AlertCircle, + CheckCircle2, + ExternalLink, + Link2, + Link2Off, + Loader2, + FileCheck, + Lock, + Unlock, + Send, + ShieldAlert, +} from 'lucide-react' +import type { VatPeriodType } from '@/types' +import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format' + +interface SkatteverketStatus { + connected: boolean + expired?: boolean + canRefresh?: boolean + scope?: string + expiresAt?: string +} + +interface KontrollResult { + id: string + typ: 'ERROR' | 'WARNING' + text: string +} + +interface SkatteverketPanelProps { + periodType: VatPeriodType + year: number + period: number + hasData: boolean +} + +function formatAmount(amount: number): string { + return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +const SKV_ENABLED = ENABLED_EXTENSION_IDS.has('skatteverket') + +export function SkatteverketPanel(props: SkatteverketPanelProps) { + if (!SKV_ENABLED) return null + return +} + +function SkatteverketPanelInner({ periodType, year, period, hasData }: SkatteverketPanelProps) { + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [actionLoading, setActionLoading] = useState(null) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [kontroller, setKontroller] = useState([]) + const [signeringslank, setSigneringslank] = useState(null) + const [submitted, setSubmitted] = useState<{ + kvittensnummer?: string + tidpunkt?: string + } | null>(null) + + const fetchStatus = useCallback(async () => { + try { + const res = await fetch('/api/extensions/ext/skatteverket/status') + if (res.ok) { + const data = await res.json() + setStatus(data) + } + } catch { + // Extension might not be enabled + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchStatus() + + // Check URL params for OAuth callback results + const params = new URLSearchParams(window.location.search) + if (params.get('skv_connected') === 'true') { + setSuccess('Ansluten till Skatteverket') + fetchStatus() + // Clean URL + const url = new URL(window.location.href) + url.searchParams.delete('skv_connected') + window.history.replaceState({}, '', url.toString()) + } + const skvError = params.get('skv_error') + if (skvError) { + setError(decodeURIComponent(skvError)) + const url = new URL(window.location.href) + url.searchParams.delete('skv_error') + window.history.replaceState({}, '', url.toString()) + } + }, [fetchStatus]) + + const handleConnect = () => { + window.location.href = '/api/extensions/ext/skatteverket/authorize' + } + + const handleDisconnect = async () => { + setActionLoading('disconnect') + setError(null) + try { + const res = await fetch('/api/extensions/ext/skatteverket/disconnect', { + method: 'POST', + }) + if (res.ok) { + setStatus({ connected: false }) + setSuccess(null) + setKontroller([]) + setSigneringslank(null) + setSubmitted(null) + } + } catch { + setError('Kunde inte koppla bort') + } finally { + setActionLoading(null) + } + } + + const handleValidate = async () => { + setActionLoading('validate') + setError(null) + setKontroller([]) + try { + const res = await fetch('/api/extensions/ext/skatteverket/declaration/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ periodType, year, period }), + }) + const result = await res.json() + if (result.error) { + setError(result.error) + } else { + const controls = result.data?.kontrollresultat?.kontroller || [] + setKontroller(controls) + if (controls.length === 0) { + setSuccess('Valideringen godkänd — inga fel eller varningar') + } else { + const errors = controls.filter((k: KontrollResult) => k.typ === 'ERROR') + if (errors.length > 0) { + setError(`${errors.length} valideringsfel hittades`) + } else { + setSuccess('Valideringen godkänd med varningar') + } + } + } + } catch { + setError('Kunde inte validera deklarationen') + } finally { + setActionLoading(null) + } + } + + const handleSaveDraft = async () => { + setActionLoading('draft') + setError(null) + try { + const res = await fetch('/api/extensions/ext/skatteverket/declaration/draft', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ periodType, year, period }), + }) + const result = await res.json() + if (result.error) { + setError(result.error) + } else { + const controls = result.data?.kontrollresultat?.kontroller || [] + setKontroller(controls) + const errors = controls.filter((k: KontrollResult) => k.typ === 'ERROR') + if (errors.length === 0) { + setSuccess('Utkast sparat i Eget utrymme hos Skatteverket') + } else { + setError(`Utkastet sparades men har ${errors.length} valideringsfel`) + } + } + } catch { + setError('Kunde inte spara utkast') + } finally { + setActionLoading(null) + } + } + + const handleLock = async () => { + setActionLoading('lock') + setError(null) + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/declaration/lock?redovisare=${encodeURIComponent( + await getRedovisare() + )}&redovisningsperiod=${getRedovisningsperiod()}`, + { method: 'PUT' } + ) + const result = await res.json() + if (result.error) { + setError(result.error) + } else if (result.data?.signeringslank) { + setSigneringslank(result.data.signeringslank) + setSuccess('Utkastet är låst. Öppna signeringslänken för att signera med BankID.') + } + } catch { + setError('Kunde inte låsa utkastet') + } finally { + setActionLoading(null) + } + } + + const handleUnlock = async () => { + setActionLoading('unlock') + setError(null) + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/declaration/lock?redovisare=${encodeURIComponent( + await getRedovisare() + )}&redovisningsperiod=${getRedovisningsperiod()}`, + { method: 'DELETE' } + ) + const result = await res.json() + if (result.error) { + setError(result.error) + } else { + setSigneringslank(null) + setSuccess('Utkastet har låsts upp') + } + } catch { + setError('Kunde inte låsa upp utkastet') + } finally { + setActionLoading(null) + } + } + + const handleCheckSubmitted = async () => { + setActionLoading('check') + setError(null) + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/declaration/submitted?redovisare=${encodeURIComponent( + await getRedovisare() + )}&redovisningsperiod=${getRedovisningsperiod()}` + ) + const result = await res.json() + if (result.error) { + setError(result.error) + } else if (result.data) { + setSubmitted(result.data) + setSuccess('Deklarationen har lämnats in') + } else { + setSuccess('Ingen inlämnad deklaration hittades för denna period') + } + } catch { + setError('Kunde inte kontrollera inlämningsstatus') + } finally { + setActionLoading(null) + } + } + + // Helper to get redovisare from settings + const getRedovisare = async (): Promise => { + const res = await fetch('/api/settings') + const { data } = await res.json() + if (!data?.org_number) throw new Error('Organisationsnummer saknas') + return formatRedovisare(data.org_number, data.entity_type) + } + + const getRedovisningsperiod = (): string => { + return formatRedovisningsperiod(periodType, year, period) + } + + if (loading) { + return ( + + + + Kontrollerar Skatteverket-anslutning... + + + ) + } + + // Not connected + if (!status?.connected) { + return ( + + + + + Skicka till Skatteverket + + + + {error && ( +
+ + {error} +
+ )} +

+ Anslut till Skatteverket med BankID för att skicka momsdeklarationen direkt. +

+ +
+
+ ) + } + + // Connected — show actions + const hasErrors = kontroller.some(k => k.typ === 'ERROR') + const hasWarnings = kontroller.some(k => k.typ === 'WARNING') + + return ( + + +
+ + + Skicka till Skatteverket + +
+ + + Ansluten + + {status.expired && ( + + + Session utgången + + )} +
+
+
+ + {/* Messages */} + {error && ( +
+ + {error} +
+ )} + {success && !error && ( +
+ + {success} +
+ )} + + {/* Validation results */} + {kontroller.length > 0 && ( +
+

+ Valideringsresultat +

+ {kontroller.map((k, i) => ( +
+ {k.typ === 'ERROR' ? ( + + ) : ( + + )} +
+ {k.id} + {k.text} +
+
+ ))} +
+ )} + + {/* Submitted confirmation */} + {submitted && ( +
+

+ + Inlämnad +

+ {submitted.kvittensnummer && ( +

+ Kvittensnummer: {submitted.kvittensnummer} +

+ )} + {submitted.tidpunkt && ( +

+ Tidpunkt: {new Date(submitted.tidpunkt).toLocaleString('sv-SE')} +

+ )} +
+ )} + + {/* Signing link */} + {signeringslank && ( +
+

Utkastet är låst och redo att signeras

+

+ Öppna länken nedan och signera med BankID på Skatteverkets sida. +

+ +
+ )} + + {/* Action buttons */} +
+ + + + + {!signeringslank ? ( + + ) : ( + + )} + + +
+ + {/* Disconnect */} +
+ +
+
+
+ ) +} diff --git a/extensions.schema.json b/extensions.schema.json index a5d767e0..3d07294c 100644 --- a/extensions.schema.json +++ b/extensions.schema.json @@ -26,7 +26,8 @@ "email", "arcim-migration", "tic", - "mcp-server" + "mcp-server", + "skatteverket" ] }, "description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory." diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 3ba4e452..1ece95ce 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -7,6 +7,7 @@ import { import type { SupabaseClient } from '@supabase/supabase-js' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' +import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' import { eventBus } from '@/lib/events/bus' import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' @@ -238,6 +239,15 @@ async function categorizeTransactionCore( }, }) + // Upsert counterparty template for future auto-matching + try { + await upsertCounterpartyTemplate( + supabase, userId, transaction as Transaction, mappingResult, 'user_approved' + ) + } catch { + // Non-critical + } + return { success: true, journal_entry_created: !!journalEntryId, diff --git a/extensions/general/skatteverket/__tests__/mappers.test.ts b/extensions/general/skatteverket/__tests__/mappers.test.ts new file mode 100644 index 00000000..99b2ee44 --- /dev/null +++ b/extensions/general/skatteverket/__tests__/mappers.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest' +import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from '../lib/mappers' +import type { VatDeclarationRutor } from '@/types' + +const emptyRutor: VatDeclarationRutor = { + ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0, + ruta10: 0, ruta11: 0, ruta12: 0, + ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0, + ruta30: 0, ruta31: 0, ruta32: 0, + ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0, + ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0, + ruta48: 0, ruta49: 0, + ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0, +} + +describe('rutorToMomsuppgift', () => { + it('converts typical EF VAT declaration', () => { + const rutor: VatDeclarationRutor = { + ...emptyRutor, + ruta05: 10000, + ruta10: 2500, + ruta48: 350, + ruta49: 2150, + } + + const result = rutorToMomsuppgift(rutor) + + expect(result.momspliktigForsaljning).toBe(10000) + expect(result.momsForsaljningUtgaendeHog).toBe(2500) + expect(result.ingaendeMomsAvdrag).toBe(350) + expect(result.summaMoms).toBe(2150) + }) + + it('omits zero-value fields except summaMoms', () => { + const rutor: VatDeclarationRutor = { + ...emptyRutor, + ruta05: 5000, + ruta10: 1250, + ruta49: 1250, + } + + const result = rutorToMomsuppgift(rutor) + + expect(result.momspliktigForsaljning).toBe(5000) + expect(result.momsForsaljningUtgaendeHog).toBe(1250) + expect(result.summaMoms).toBe(1250) + // Zero fields should not be present + expect(result.momspliktigaUttag).toBeUndefined() + expect(result.momsForsaljningUtgaendeMedel).toBeUndefined() + expect(result.momsForsaljningUtgaendeLag).toBeUndefined() + expect(result.ingaendeMomsAvdrag).toBeUndefined() + }) + + it('always includes summaMoms even when zero', () => { + const result = rutorToMomsuppgift(emptyRutor) + expect(result.summaMoms).toBe(0) + }) + + it('maps reverse charge fields correctly', () => { + const rutor: VatDeclarationRutor = { + ...emptyRutor, + ruta21: 5000, + ruta30: 1250, + ruta48: 1250, + ruta49: 0, + } + + const result = rutorToMomsuppgift(rutor) + + expect(result.inkopTjansterEU).toBe(5000) + expect(result.momsInkopUtgaendeHog).toBe(1250) + expect(result.ingaendeMomsAvdrag).toBe(1250) + expect(result.summaMoms).toBe(0) + }) + + it('maps EU/export and import fields', () => { + const rutor: VatDeclarationRutor = { + ...emptyRutor, + ruta35: 8000, + ruta36: 12000, + ruta39: 3000, + ruta40: 5000, + ruta50: 2000, + ruta60: 500, + ruta49: 500, + } + + const result = rutorToMomsuppgift(rutor) + + expect(result.forsaljningVarorEU).toBe(8000) + expect(result.forsaljningVarorUtanforEU).toBe(12000) + expect(result.forsaljningTjansterEU).toBe(3000) + expect(result.ovrigForsaljningTjansterUtanforSE).toBe(5000) + expect(result.import).toBe(2000) + expect(result.momsImportUtgaendeHog).toBe(500) + }) +}) + +describe('formatRedovisare', () => { + it('formats aktiebolag org number with 16 prefix', () => { + expect(formatRedovisare('5020000013', 'aktiebolag')).toBe('165020000013') + }) + + it('formats aktiebolag org number with hyphen', () => { + expect(formatRedovisare('502000-0013', 'aktiebolag')).toBe('165020000013') + }) + + it('formats enskild firma personnummer with 19 prefix for older', () => { + // Person born in 1985 — 85 > 26 (current year), so prefix 19 + expect(formatRedovisare('8501011234', 'enskild_firma')).toBe('198501011234') + }) + + it('formats enskild firma personnummer with 20 prefix for younger', () => { + // Person born in 2005 — 05 < 26 (current year), so prefix 20 + expect(formatRedovisare('0501011234', 'enskild_firma')).toBe('200501011234') + }) + + it('passes through 12-digit numbers unchanged', () => { + expect(formatRedovisare('165020000013', 'aktiebolag')).toBe('165020000013') + }) + + it('throws for invalid length', () => { + expect(() => formatRedovisare('12345', 'aktiebolag')).toThrow('Ogiltigt organisationsnummer') + }) +}) + +describe('formatRedovisningsperiod', () => { + it('formats monthly period as YYYYMM', () => { + expect(formatRedovisningsperiod('monthly', 2025, 1)).toBe('202501') + expect(formatRedovisningsperiod('monthly', 2025, 12)).toBe('202512') + }) + + it('formats quarterly period as last month of quarter', () => { + expect(formatRedovisningsperiod('quarterly', 2025, 1)).toBe('202503') + expect(formatRedovisningsperiod('quarterly', 2025, 2)).toBe('202506') + expect(formatRedovisningsperiod('quarterly', 2025, 3)).toBe('202509') + expect(formatRedovisningsperiod('quarterly', 2025, 4)).toBe('202512') + }) + + it('formats yearly period as December', () => { + expect(formatRedovisningsperiod('yearly', 2025, 1)).toBe('202512') + }) +}) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts new file mode 100644 index 00000000..bf09b354 --- /dev/null +++ b/extensions/general/skatteverket/index.ts @@ -0,0 +1,648 @@ +import crypto from 'crypto' +import type { Extension, ExtensionContext } from '@/lib/extensions/types' +import { NextResponse } from 'next/server' +import { buildAuthorizeUrl, exchangeCodeForTokens } from './lib/oauth' +import { storeTokens, getTokens, deleteTokens } from './lib/token-store' +import { skvRequest, SkatteverketAuthError } from './lib/api-client' +import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from './lib/mappers' +import { calculateVatDeclaration } from '@/lib/reports/vat-declaration' +import type { VatPeriodType } from '@/types' + +/** + * Skatteverket integration extension. + * + * Enables filing momsdeklaration (VAT declaration) directly to Skatteverket + * via their Momsdeklaration API 1.0. Users authenticate with BankID through + * the `per` (e-legitimation) OAuth2 flow. + * + * Required environment variables: + * - SKATTEVERKET_OAUTH2_CLIENT_ID + * - SKATTEVERKET_OAUTH2_CLIENT_SECRET + * - SKATTEVERKET_APIGW_CLIENT_ID + * - SKATTEVERKET_APIGW_CLIENT_SECRET + * - SKATTEVERKET_TOKEN_ENCRYPTION_KEY + * + * Optional: + * - SKATTEVERKET_OAUTH_BASE_URL (defaults to test environment) + * - SKATTEVERKET_API_BASE_URL (defaults to test environment) + */ +export const skatteverketExtension: Extension = { + id: 'skatteverket', + name: 'Skatteverket Integration', + version: '1.0.0', + + settingsPanel: { + label: 'Skatteverket', + path: '/settings?tab=skatteverket', + }, + + apiRoutes: [ + // ── OAuth: Start authorization ────────────────────────────────── + // Builds the Skatteverket OAuth2 authorize URL and redirects the user + // to BankID login. Stores state token in extension settings for CSRF validation. + { + method: 'GET', + path: '/authorize', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + const state = crypto.randomUUID() + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + const redirectUri = `${appUrl}/api/extensions/ext/skatteverket/callback` + + // Store state for CSRF validation in callback + await ctx.settings.set('oauth_state', state) + await ctx.settings.set('oauth_redirect_uri', redirectUri) + + const authorizeUrl = buildAuthorizeUrl(redirectUri, state) + + return NextResponse.redirect(authorizeUrl) + }, + }, + + // ── OAuth: Callback ───────────────────────────────────────────── + // Receives the auth code from Skatteverket after BankID login. + // Exchanges code for tokens immediately (5-minute code expiry). + // skipAuth: true — browser redirect from Skatteverket. We handle + // user identification via the stored state token + Supabase session. + { + method: 'GET', + path: '/callback', + skipAuth: true, + handler: async (request: Request) => { + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + const url = new URL(request.url) + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + const error = url.searchParams.get('error') + + if (error) { + const desc = url.searchParams.get('error_description') || 'Okänt fel' + return NextResponse.redirect( + `${appUrl}/reports?tab=vat-declaration&skv_error=${encodeURIComponent(desc)}` + ) + } + + if (!code || !state) { + return NextResponse.redirect( + `${appUrl}/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Saknar auktoriseringskod')}` + ) + } + + // Exchange code FIRST — 5-minute expiry, do this before anything else + const { createClient } = await import('@/lib/supabase/server') + const supabase = await createClient() + + // Verify user session (browser should still have cookies) + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.redirect( + `${appUrl}/login?redirect=${encodeURIComponent('/reports?tab=vat-declaration')}` + ) + } + + // Validate CSRF state + const { data: settingsData } = await supabase + .from('extension_data') + .select('value') + .eq('user_id', user.id) + .eq('extension_id', 'skatteverket') + .eq('key', 'oauth_state') + .single() + + if (!settingsData || settingsData.value !== state) { + return NextResponse.redirect( + `${appUrl}/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Ogiltig state-parameter (CSRF)')}` + ) + } + + // Get the stored redirect URI + const { data: redirectData } = await supabase + .from('extension_data') + .select('value') + .eq('user_id', user.id) + .eq('extension_id', 'skatteverket') + .eq('key', 'oauth_redirect_uri') + .single() + + const redirectUri = redirectData?.value || + `${appUrl}/api/extensions/ext/skatteverket/callback` + + try { + const tokens = await exchangeCodeForTokens(code, redirectUri) + await storeTokens(supabase, user.id, tokens) + + // Clean up CSRF state + await supabase + .from('extension_data') + .delete() + .eq('user_id', user.id) + .eq('extension_id', 'skatteverket') + .eq('key', 'oauth_state') + + return NextResponse.redirect( + `${appUrl}/reports?tab=vat-declaration&skv_connected=true` + ) + } catch (err) { + console.error('[skatteverket] Token exchange failed:', err) + return NextResponse.redirect( + `${appUrl}/reports?tab=vat-declaration&skv_error=${encodeURIComponent( + err instanceof Error ? err.message : 'Token exchange misslyckades' + )}` + ) + } + }, + }, + + // ── Connection status ─────────────────────────────────────────── + { + method: 'GET', + path: '/status', + handler: async (_request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + const tokens = await getTokens(ctx.supabase, ctx.userId) + if (!tokens) { + return NextResponse.json({ connected: false }) + } + + const expired = tokens.expires_at < Date.now() + const canRefresh = tokens.refresh_token !== null && tokens.refresh_count < 10 + + return NextResponse.json({ + connected: true, + expired, + canRefresh, + scope: tokens.scope, + expiresAt: new Date(tokens.expires_at).toISOString(), + }) + }, + }, + + // ── Disconnect ────────────────────────────────────────────────── + { + method: 'POST', + path: '/disconnect', + handler: async (_request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + await deleteTokens(ctx.supabase, ctx.userId) + return NextResponse.json({ success: true }) + }, + }, + + // ── Validate declaration (dry run) ────────────────────────────── + // Sends momsuppgift to Skatteverket's /kontrollera endpoint. + // Returns ERROR/WARNING/OK without saving anything. + { + method: 'POST', + path: '/declaration/validate', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const { redovisare, redovisningsperiod, momsuppgift } = + await parseDeclarationRequest(request, ctx) + + console.log('[skatteverket] Validating:', { + redovisare, + redovisningsperiod, + momsuppgift: JSON.stringify(momsuppgift), + }) + + const response = await skvRequest( + ctx.supabase, + ctx.userId, + 'POST', + `/kontrollera/${redovisare}/${redovisningsperiod}`, + momsuppgift + ) + + if (!response.ok) { + const text = await response.text() + console.error('[skatteverket] Validate error:', response.status, text) + return NextResponse.json( + { error: `Skatteverket svarade med ${response.status}: ${text}` }, + { status: response.status } + ) + } + + const data = await response.json() + return NextResponse.json({ data }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── Save draft ────────────────────────────────────────────────── + // Saves momsuppgift to Skatteverket's "Eget utrymme". + // Returns validation results. Optionally lock for signing. + { + method: 'POST', + path: '/declaration/draft', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const { redovisare, redovisningsperiod, momsuppgift } = + await parseDeclarationRequest(request, ctx) + + console.log('[skatteverket] Sending draft:', { + redovisare, + redovisningsperiod, + momsuppgift: JSON.stringify(momsuppgift), + }) + + const response = await skvRequest( + ctx.supabase, + ctx.userId, + 'POST', + `/utkast/${redovisare}/${redovisningsperiod}`, + momsuppgift + ) + + if (!response.ok) { + const text = await response.text() + console.error('[skatteverket] Draft error:', response.status, text) + return NextResponse.json( + { error: `Skatteverket svarade med ${response.status}: ${text}` }, + { status: response.status } + ) + } + + const data = await response.json() + + // Track submission status + await ctx.settings.set( + `submission_${redovisningsperiod}`, + JSON.stringify({ + status: 'draft_saved', + redovisare, + redovisningsperiod, + kontrollresultat: data.kontrollresultat, + updatedAt: new Date().toISOString(), + }) + ) + + return NextResponse.json({ data }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── Fetch draft ───────────────────────────────────────────────── + { + method: 'GET', + path: '/declaration/draft', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx) + + const response = await skvRequest( + ctx.supabase, + ctx.userId, + 'GET', + `/utkast/${redovisare}/${redovisningsperiod}` + ) + + if (response.status === 404) { + return NextResponse.json({ data: null }) + } + + if (!response.ok) { + const text = await response.text() + return NextResponse.json( + { error: `Skatteverket svarade med ${response.status}: ${text}` }, + { status: response.status } + ) + } + + const data = await response.json() + return NextResponse.json({ data }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── Delete draft ──────────────────────────────────────────────── + { + method: 'DELETE', + path: '/declaration/draft', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx) + + const response = await skvRequest( + ctx.supabase, + ctx.userId, + 'DELETE', + `/utkast/${redovisare}/${redovisningsperiod}` + ) + + if (response.status !== 204 && !response.ok) { + const text = await response.text() + return NextResponse.json( + { error: `Skatteverket svarade med ${response.status}: ${text}` }, + { status: response.status } + ) + } + + await ctx.settings.set(`submission_${redovisningsperiod}`, null) + return NextResponse.json({ success: true }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── Lock draft for signing ────────────────────────────────────── + // Returns a signeringslänk (deep link) that the user opens + // in a new tab to sign with BankID on Skatteverket's site. + { + method: 'PUT', + path: '/declaration/lock', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx) + + const response = await skvRequest( + ctx.supabase, + ctx.userId, + 'PUT', + `/las/${redovisare}/${redovisningsperiod}` + ) + + if (!response.ok) { + const text = await response.text() + return NextResponse.json( + { error: `Skatteverket svarade med ${response.status}: ${text}` }, + { status: response.status } + ) + } + + const data = await response.json() + + await ctx.settings.set( + `submission_${redovisningsperiod}`, + JSON.stringify({ + status: 'draft_locked', + redovisare, + redovisningsperiod, + signeringslank: data.signeringslank, + updatedAt: new Date().toISOString(), + }) + ) + + return NextResponse.json({ data }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── Unlock draft ──────────────────────────────────────────────── + { + method: 'DELETE', + path: '/declaration/lock', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx) + + const response = await skvRequest( + ctx.supabase, + ctx.userId, + 'DELETE', + `/las/${redovisare}/${redovisningsperiod}` + ) + + if (response.status !== 204 && !response.ok) { + const text = await response.text() + return NextResponse.json( + { error: `Skatteverket svarade med ${response.status}: ${text}` }, + { status: response.status } + ) + } + + await ctx.settings.set( + `submission_${redovisningsperiod}`, + JSON.stringify({ + status: 'draft_saved', + redovisare, + redovisningsperiod, + updatedAt: new Date().toISOString(), + }) + ) + + return NextResponse.json({ success: true }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── Fetch submitted declaration ───────────────────────────────── + { + method: 'GET', + path: '/declaration/submitted', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx) + + const response = await skvRequest( + ctx.supabase, + ctx.userId, + 'GET', + `/inlamnat/${redovisare}/${redovisningsperiod}` + ) + + if (response.status === 404) { + return NextResponse.json({ data: null }) + } + + if (!response.ok) { + const text = await response.text() + return NextResponse.json( + { error: `Skatteverket svarade med ${response.status}: ${text}` }, + { status: response.status } + ) + } + + const data = await response.json() + return NextResponse.json({ data }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + + // ── Fetch decided declaration ─────────────────────────────────── + { + method: 'GET', + path: '/declaration/decided', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + try { + const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx) + + const response = await skvRequest( + ctx.supabase, + ctx.userId, + 'GET', + `/beslutat/${redovisare}/${redovisningsperiod}` + ) + + if (response.status === 404) { + return NextResponse.json({ data: null }) + } + + if (!response.ok) { + const text = await response.text() + return NextResponse.json( + { error: `Skatteverket svarade med ${response.status}: ${text}` }, + { status: response.status } + ) + } + + const data = await response.json() + return NextResponse.json({ data }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + ], +} + +// ── Helpers ─────────────────────────────────────────────────────────── + +/** + * Parse and validate declaration request body. + * Computes momsuppgift from gnubok's VAT calculation if not provided directly. + */ +async function parseDeclarationRequest( + request: Request, + ctx: ExtensionContext +): Promise<{ + redovisare: string + redovisningsperiod: string + momsuppgift: ReturnType +}> { + const body = await request.json() + const { periodType, year, period } = body as { + periodType: VatPeriodType + year: number + period: number + } + + if (!periodType || !year || !period) { + throw new Error('Saknar obligatoriska fält: periodType, year, period') + } + + // Get company settings for redovisare formatting + const { data: settings } = await ctx.supabase + .from('company_settings') + .select('org_number, entity_type') + .eq('user_id', ctx.userId) + .single() + + if (!settings?.org_number) { + throw new Error('Organisationsnummer saknas i företagsinställningar') + } + + const redovisare = formatRedovisare(settings.org_number, settings.entity_type) + const redovisningsperiod = formatRedovisningsperiod(periodType, year, period) + + // Calculate VAT declaration from the general ledger + const declaration = await calculateVatDeclaration( + ctx.supabase, + ctx.userId, + periodType, + year, + period + ) + + const momsuppgift = rutorToMomsuppgift(declaration.rutor) + + return { redovisare, redovisningsperiod, momsuppgift } +} + +/** + * Parse redovisare and redovisningsperiod from query params. + * Used by GET/PUT/DELETE endpoints that don't need a full body. + */ +function parseQueryParams( + request: Request, + ctx: ExtensionContext +): { redovisare: string; redovisningsperiod: string } { + const url = new URL(request.url) + const redovisare = url.searchParams.get('redovisare') + const redovisningsperiod = url.searchParams.get('redovisningsperiod') + + if (!redovisare || !redovisningsperiod) { + throw new Error('Saknar obligatoriska parametrar: redovisare, redovisningsperiod') + } + + // Suppress unused variable warning — ctx is required by the type signature + void ctx + + return { redovisare, redovisningsperiod } +} + +/** + * Convert Skatteverket errors to appropriate HTTP responses. + */ +function handleSkvError(err: unknown): NextResponse { + if (err instanceof SkatteverketAuthError) { + const status = err.code === 'NOT_CONNECTED' ? 401 + : err.code === 'BEHORIGHET_SAKNAS' ? 403 + : err.code === 'SESSION_EXPIRED' || err.code === 'REFRESH_EXHAUSTED' ? 401 + : 403 + + return NextResponse.json( + { error: err.message, code: err.code }, + { status } + ) + } + + console.error('[skatteverket] API error:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Okänt fel' }, + { status: 500 } + ) +} diff --git a/extensions/general/skatteverket/lib/api-client.ts b/extensions/general/skatteverket/lib/api-client.ts new file mode 100644 index 00000000..cbb26e4e --- /dev/null +++ b/extensions/general/skatteverket/lib/api-client.ts @@ -0,0 +1,182 @@ +import crypto from 'crypto' +import type { SupabaseClient } from '@supabase/supabase-js' +import { refreshAccessToken } from './oauth' +import { getTokens, storeTokens } from './token-store' +import type { SkatteverketTokens } from '../types' + +/** + * Skatteverket API client. + * + * Handles: + * - Automatic token refresh (transparent to callers) + * - Required API gateway headers + * - Rate limiting (4 req/sec per consumer) + * - Correlation ID generation + */ + +const DEFAULT_API_BASE_URL = 'https://api.test.skatteverket.se/momsdeklaration/v1' +const MAX_REFRESH_COUNT = 10 +const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000 // Refresh 5 min before expiry + +// Simple in-memory token bucket for 4 req/sec rate limit +let lastRequestTime = 0 +const MIN_REQUEST_INTERVAL_MS = 250 // 1000ms / 4 = 250ms + +function getApiBaseUrl(): string { + return process.env.SKATTEVERKET_API_BASE_URL || DEFAULT_API_BASE_URL +} + +function getApiGwClientId(): string { + const id = process.env.SKATTEVERKET_APIGW_CLIENT_ID + if (!id) throw new Error('SKATTEVERKET_APIGW_CLIENT_ID is required') + return id +} + +function getApiGwClientSecret(): string { + const secret = process.env.SKATTEVERKET_APIGW_CLIENT_SECRET + if (!secret) throw new Error('SKATTEVERKET_APIGW_CLIENT_SECRET is required') + return secret +} + +/** + * Ensure rate limit compliance (4 req/sec). + * Delays if the last request was too recent. + */ +async function enforceRateLimit(): Promise { + const now = Date.now() + const elapsed = now - lastRequestTime + lastRequestTime = now // Claim the slot immediately to prevent concurrent bypass + if (elapsed < MIN_REQUEST_INTERVAL_MS) { + await new Promise(resolve => setTimeout(resolve, MIN_REQUEST_INTERVAL_MS - elapsed)) + } +} + +/** + * Get a valid access token, refreshing if needed. + * Throws if no tokens exist or refresh is exhausted. + */ +async function getValidToken( + supabase: SupabaseClient, + userId: string +): Promise { + const tokens = await getTokens(supabase, userId) + if (!tokens) { + throw new SkatteverketAuthError( + 'Inte ansluten till Skatteverket. Anslut med BankID först.', + 'NOT_CONNECTED' + ) + } + + // Token still valid (with 5-min margin) + if (tokens.expires_at > Date.now() + TOKEN_REFRESH_MARGIN_MS) { + return tokens.access_token + } + + // Need refresh + if (!tokens.refresh_token) { + throw new SkatteverketAuthError( + 'Sessionen har gått ut. Logga in med BankID igen.', + 'SESSION_EXPIRED' + ) + } + + if (tokens.refresh_count >= MAX_REFRESH_COUNT) { + throw new SkatteverketAuthError( + 'Maximalt antal förnyelser uppnått. Logga in med BankID igen.', + 'REFRESH_EXHAUSTED' + ) + } + + // Refresh — returns NEW refresh_token that must be stored + const refreshed = await refreshAccessToken(tokens.refresh_token, tokens.refresh_count) + const updatedTokens: SkatteverketTokens = { + ...refreshed, + scope: tokens.scope, + } + await storeTokens(supabase, userId, updatedTokens) + + return updatedTokens.access_token +} + +/** + * Make an authenticated request to the Skatteverket API. + * + * Automatically handles: + * - Token refresh if expired + * - Required headers (Client_Id, Client_Secret, correlation ID) + * - Rate limiting + */ +export async function skvRequest( + supabase: SupabaseClient, + userId: string, + method: string, + path: string, + body?: unknown +): Promise { + const accessToken = await getValidToken(supabase, userId) + + await enforceRateLimit() + + const url = `${getApiBaseUrl()}${path}` + const headers: Record = { + 'Authorization': `Bearer ${accessToken}`, + 'Client_Id': getApiGwClientId(), + 'Client_Secret': getApiGwClientSecret(), + 'skv_client_correlation_id': crypto.randomUUID(), + } + + if (body !== undefined) { + headers['Content-Type'] = 'application/json' + } + + const response = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + + // Handle Skatteverket-specific auth errors + if (response.status === 403) { + const text = await response.text() + // Behörighet saknas — user is authenticated but not authorized for this company + if (text.includes('Behörighet') || text.includes('behörighet')) { + throw new SkatteverketAuthError( + 'Du har inte behörighet att agera för detta företag hos Skatteverket. ' + + 'Kontrollera att du är registrerad som firmatecknare eller deklarationsombud.', + 'BEHORIGHET_SAKNAS' + ) + } + throw new SkatteverketAuthError( + `Åtkomst nekad av Skatteverket (403): ${text}`, + 'ACCESS_DENIED' + ) + } + + if (response.status === 401) { + throw new SkatteverketAuthError( + 'Sessionen har gått ut. Logga in med BankID igen.', + 'SESSION_EXPIRED' + ) + } + + return response +} + +/** + * Structured error for Skatteverket auth/access issues. + * The `code` field helps the frontend show appropriate UI. + */ +export class SkatteverketAuthError extends Error { + constructor( + message: string, + public readonly code: + | 'NOT_CONNECTED' + | 'SESSION_EXPIRED' + | 'REFRESH_EXHAUSTED' + | 'BEHORIGHET_SAKNAS' + | 'ACCESS_DENIED' + ) { + super(message) + this.name = 'SkatteverketAuthError' + } +} diff --git a/extensions/general/skatteverket/lib/mappers.ts b/extensions/general/skatteverket/lib/mappers.ts new file mode 100644 index 00000000..02c4a049 --- /dev/null +++ b/extensions/general/skatteverket/lib/mappers.ts @@ -0,0 +1,68 @@ +import type { VatDeclarationRutor } from '@/types' +import type { SkatteverketMomsuppgift } from '../types' + +// Re-export shared formatting utilities +export { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format' + +/** + * Convert gnubok VatDeclarationRutor to Skatteverket's momsuppgift payload. + * + * Fields with value 0 are omitted (Skatteverket treats absent fields as 0). + * This keeps the payload clean and avoids sending unnecessary data. + */ +export function rutorToMomsuppgift(rutor: VatDeclarationRutor): SkatteverketMomsuppgift { + const result: SkatteverketMomsuppgift = {} + + // Helper: only set non-zero values, rounded to whole kronor (SKV expects integers) + const set = (key: keyof SkatteverketMomsuppgift, value: number) => { + if (value !== 0) result[key] = Math.round(value) + } + + // Taxable sales basis + set('momspliktigForsaljning', rutor.ruta05) + set('momspliktigaUttag', rutor.ruta06) + set('vinstmarginal', rutor.ruta07) + set('hyresInkomst', rutor.ruta08) + + // Output VAT on sales + set('momsForsaljningUtgaendeHog', rutor.ruta10) + set('momsForsaljningUtgaendeMedel', rutor.ruta11) + set('momsForsaljningUtgaendeLag', rutor.ruta12) + + // Reverse charge purchase bases + set('inkopVarorEU', rutor.ruta20) + set('inkopTjansterEU', rutor.ruta21) + set('inkopTjansterUtanforEU', rutor.ruta22) + set('inkopVarorSE', rutor.ruta23) + set('inkopTjansterSE', rutor.ruta24) + + // Output VAT on reverse charge purchases + set('momsInkopUtgaendeHog', rutor.ruta30) + set('momsInkopUtgaendeMedel', rutor.ruta31) + set('momsInkopUtgaendeLag', rutor.ruta32) + + // EU/export sales + set('forsaljningVarorEU', rutor.ruta35) + set('forsaljningVarorUtanforEU', rutor.ruta36) + set('inkopVaror3pHandel', rutor.ruta37) + set('forsaljningVaror3pHandel', rutor.ruta38) + set('forsaljningTjansterEU', rutor.ruta39) + set('ovrigForsaljningTjansterUtanforSE', rutor.ruta40) + set('forsaljningBskKopareSE', rutor.ruta41) + set('momsfriForsaljning', rutor.ruta42) + + // Input VAT + set('ingaendeMomsAvdrag', rutor.ruta48) + + // Net VAT (must always be present, whole kronor) + result.summaMoms = Math.round(rutor.ruta49) + + // Import + set('import', rutor.ruta50) + set('momsImportUtgaendeHog', rutor.ruta60) + set('momsImportUtgaendeMedel', rutor.ruta61) + set('momsImportUtgaendeLag', rutor.ruta62) + + return result +} + diff --git a/extensions/general/skatteverket/lib/oauth.ts b/extensions/general/skatteverket/lib/oauth.ts new file mode 100644 index 00000000..1ed0b838 --- /dev/null +++ b/extensions/general/skatteverket/lib/oauth.ts @@ -0,0 +1,134 @@ +import type { SkatteverketTokens } from '../types' + +/** + * Skatteverket OAuth2 helpers for the `per` (BankID) flow. + * + * Endpoints: + * Authorize: GET {base}/authorize + * Token: POST {base}/token + * + * The `per` flow is user-facing BankID authentication. + * No mTLS required (unlike the `org` flow). + */ + +const DEFAULT_OAUTH_BASE_URL = 'https://peroauth2.test.skatteverket.se/oauth2/v1/per' +const DEFAULT_SCOPES = 'momsdeklaration inkforetag ska skahmst' + +function getOAuthBaseUrl(): string { + return process.env.SKATTEVERKET_OAUTH_BASE_URL || DEFAULT_OAUTH_BASE_URL +} + +function getClientId(): string { + const id = process.env.SKATTEVERKET_OAUTH2_CLIENT_ID + if (!id) throw new Error('SKATTEVERKET_OAUTH2_CLIENT_ID is required') + return id +} + +function getClientSecret(): string { + const secret = process.env.SKATTEVERKET_OAUTH2_CLIENT_SECRET + if (!secret) throw new Error('SKATTEVERKET_OAUTH2_CLIENT_SECRET is required') + return secret +} + +/** + * Build the Skatteverket OAuth2 authorization URL. + * User is redirected here to authenticate with BankID. + */ +export function buildAuthorizeUrl( + redirectUri: string, + state: string, + scope?: string +): string { + const base = getOAuthBaseUrl() + const params = new URLSearchParams({ + client_id: getClientId(), + response_type: 'code', + state, + redirect_uri: redirectUri, + scope: scope || DEFAULT_SCOPES, + }) + return `${base}/authorize?${params.toString()}` +} + +/** + * Exchange an authorization code for tokens. + * Must be called immediately upon receiving the callback — code expires in 5 minutes. + */ +export async function exchangeCodeForTokens( + code: string, + redirectUri: string +): Promise { + const base = getOAuthBaseUrl() + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: getClientId(), + client_secret: getClientSecret(), + redirect_uri: redirectUri, + code, + }) + + const response = await fetch(`${base}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: body.toString(), + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Skatteverket token exchange failed (${response.status}): ${text}`) + } + + const data = await response.json() + + return { + access_token: data.access_token, + refresh_token: data.refresh_token ?? null, + expires_at: Date.now() + (data.expires_in ?? 3600) * 1000, + refresh_count: 0, + scope: data.scope ?? DEFAULT_SCOPES, + } +} + +/** + * Refresh an access token using a stored refresh token. + * + * The `per` flow supports up to 10 refreshes per session. + * Each refresh returns a NEW refresh_token that must be stored. + * Refresh tokens are valid for 65 minutes. + */ +export async function refreshAccessToken( + refreshToken: string, + previousRefreshCount: number +): Promise { + const base = getOAuthBaseUrl() + + const body = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: getClientId(), + client_secret: getClientSecret(), + refresh_token: refreshToken, + }) + + const response = await fetch(`${base}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: body.toString(), + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Skatteverket token refresh failed (${response.status}): ${text}`) + } + + const data = await response.json() + + return { + access_token: data.access_token, + // Each refresh returns a new refresh_token — must be stored + refresh_token: data.refresh_token ?? null, + expires_at: Date.now() + (data.expires_in ?? 3600) * 1000, + refresh_count: previousRefreshCount + 1, + scope: data.scope ?? '', + } +} diff --git a/extensions/general/skatteverket/lib/token-store.ts b/extensions/general/skatteverket/lib/token-store.ts new file mode 100644 index 00000000..1267b162 --- /dev/null +++ b/extensions/general/skatteverket/lib/token-store.ts @@ -0,0 +1,112 @@ +import crypto from 'crypto' +import type { SupabaseClient } from '@supabase/supabase-js' +import type { SkatteverketTokens } from '../types' + +/** + * Encrypted token storage for Skatteverket OAuth2 tokens. + * + * Uses AES-256-GCM with a dedicated encryption key (not the Supabase service + * role key) to encrypt tokens at rest in the skatteverket_tokens table. + * + * Pattern mirrors lib/auth/oauth-codes.ts but adapted for persistent storage. + */ + +const ALGORITHM = 'aes-256-gcm' + +function getEncryptionKey(): Buffer { + const key = process.env.SKATTEVERKET_TOKEN_ENCRYPTION_KEY + if (!key) throw new Error('SKATTEVERKET_TOKEN_ENCRYPTION_KEY is required') + return crypto.createHash('sha256').update(key).digest() +} + +function encrypt(plaintext: string): string { + const key = getEncryptionKey() + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return Buffer.concat([iv, tag, encrypted]).toString('base64url') +} + +function decrypt(ciphertext: string): string { + const key = getEncryptionKey() + const combined = Buffer.from(ciphertext, 'base64url') + const iv = combined.subarray(0, 12) + const tag = combined.subarray(12, 28) + const encrypted = combined.subarray(28) + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + decipher.setAuthTag(tag) + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8') +} + +/** + * Store (upsert) Skatteverket tokens for a user. + * Both access_token and refresh_token are encrypted at rest. + */ +export async function storeTokens( + supabase: SupabaseClient, + userId: string, + tokens: SkatteverketTokens +): Promise { + const encryptedAccess = encrypt(tokens.access_token) + const encryptedRefresh = tokens.refresh_token ? encrypt(tokens.refresh_token) : null + + const { error } = await supabase + .from('skatteverket_tokens') + .upsert( + { + user_id: userId, + access_token: encryptedAccess, + refresh_token: encryptedRefresh, + expires_at: new Date(tokens.expires_at).toISOString(), + refresh_count: tokens.refresh_count, + scope: tokens.scope, + }, + { onConflict: 'user_id' } + ) + + if (error) throw new Error(`Failed to store tokens: ${error.message}`) +} + +/** + * Retrieve and decrypt Skatteverket tokens for a user. + * Returns null if no tokens are stored. + */ +export async function getTokens( + supabase: SupabaseClient, + userId: string +): Promise { + const { data, error } = await supabase + .from('skatteverket_tokens') + .select('access_token, refresh_token, expires_at, refresh_count, scope') + .eq('user_id', userId) + .single() + + if (error || !data) return null + + try { + return { + access_token: decrypt(data.access_token), + refresh_token: data.refresh_token ? decrypt(data.refresh_token) : null, + expires_at: new Date(data.expires_at).getTime(), + refresh_count: data.refresh_count ?? 0, + scope: data.scope, + } + } catch { + // Decryption failed — key may have rotated, tokens are invalid + return null + } +} + +/** + * Delete stored tokens (disconnect from Skatteverket). + */ +export async function deleteTokens( + supabase: SupabaseClient, + userId: string +): Promise { + await supabase + .from('skatteverket_tokens') + .delete() + .eq('user_id', userId) +} diff --git a/extensions/general/skatteverket/manifest.json b/extensions/general/skatteverket/manifest.json new file mode 100644 index 00000000..8aeaa19c --- /dev/null +++ b/extensions/general/skatteverket/manifest.json @@ -0,0 +1,27 @@ +{ + "id": "skatteverket", + "sector": "general", + "exportName": "skatteverketExtension", + "entryPoint": "@/extensions/general/skatteverket", + "workspace": null, + "requiredEnvVars": [ + "SKATTEVERKET_OAUTH2_CLIENT_ID", + "SKATTEVERKET_OAUTH2_CLIENT_SECRET", + "SKATTEVERKET_APIGW_CLIENT_ID", + "SKATTEVERKET_APIGW_CLIENT_SECRET", + "SKATTEVERKET_TOKEN_ENCRYPTION_KEY" + ], + "optionalEnvVars": [ + "SKATTEVERKET_OAUTH_BASE_URL", + "SKATTEVERKET_API_BASE_URL" + ], + "npmDependencies": [], + "definition": { + "name": "Skatteverket Integration", + "category": "operations", + "icon": "FileCheck", + "dataPattern": "core", + "description": "Skicka momsdeklaration direkt till Skatteverket via BankID.", + "longDescription": "Anslut till Skatteverket med BankID och skicka din momsdeklaration direkt från gnubok. Spara utkast, validera, lås och signera — utan att lämna appen." + } +} diff --git a/extensions/general/skatteverket/types.ts b/extensions/general/skatteverket/types.ts new file mode 100644 index 00000000..26c718ea --- /dev/null +++ b/extensions/general/skatteverket/types.ts @@ -0,0 +1,99 @@ +/** + * Skatteverket API types for Momsdeklaration 1.0 + * + * Field names match Skatteverket's JSON schema exactly. + * Reference: Tjänstebeskrivning Momsdeklaration v1.5 + */ + +/** Momsuppgift payload — maps 1:1 to SKV 4700 boxes */ +export interface SkatteverketMomsuppgift { + momspliktigForsaljning?: number // Box 05 + momspliktigaUttag?: number // Box 06 + vinstmarginal?: number // Box 07 + hyresInkomst?: number // Box 08 + momsForsaljningUtgaendeHog?: number // Box 10 + momsForsaljningUtgaendeMedel?: number // Box 11 + momsForsaljningUtgaendeLag?: number // Box 12 + inkopVarorEU?: number // Box 20 + inkopTjansterEU?: number // Box 21 + inkopTjansterUtanforEU?: number // Box 22 + inkopVarorSE?: number // Box 23 + inkopTjansterSE?: number // Box 24 + momsInkopUtgaendeHog?: number // Box 30 + momsInkopUtgaendeMedel?: number // Box 31 + momsInkopUtgaendeLag?: number // Box 32 + forsaljningVarorEU?: number // Box 35 + forsaljningVarorUtanforEU?: number // Box 36 + inkopVaror3pHandel?: number // Box 37 + forsaljningVaror3pHandel?: number // Box 38 + forsaljningTjansterEU?: number // Box 39 + ovrigForsaljningTjansterUtanforSE?: number // Box 40 + forsaljningBskKopareSE?: number // Box 41 + momsfriForsaljning?: number // Box 42 + ingaendeMomsAvdrag?: number // Box 48 + summaMoms?: number // Box 49 + import?: number // Box 50 + momsImportUtgaendeHog?: number // Box 60 + momsImportUtgaendeMedel?: number // Box 61 + momsImportUtgaendeLag?: number // Box 62 +} + +/** Validation result from Skatteverket /kontrollera or /utkast */ +export interface SkatteverketKontrollresultat { + kontroller?: SkatteverketKontroll[] +} + +export interface SkatteverketKontroll { + id: string // FK001, RK002, etc. + typ: 'ERROR' | 'WARNING' + text: string +} + +/** Response from saving a draft */ +export interface SkatteverketUtkastResponse { + kontrollresultat?: SkatteverketKontrollresultat + signeringslank?: string +} + +/** Response from fetching submitted declarations */ +export interface SkatteverketInlamnatResponse { + kvittensnummer?: string + tidpunkt?: string // ISO 8601 timestamp + signerare?: string // Personnummer of signer +} + +/** Response from fetching decisions */ +export interface SkatteverketBeslutatResponse { + beslutsdatum?: string + momsBeslut?: SkatteverketMomsuppgift +} + +/** Stored token pair (decrypted form) */ +export interface SkatteverketTokens { + access_token: string + refresh_token: string | null + expires_at: number // Unix timestamp ms + refresh_count: number + scope: string +} + +/** Declaration submission status tracking */ +export type DeclarationStatus = + | 'draft_saved' + | 'draft_locked' + | 'signed' + | 'decided' + +export interface SkatteverketSubmission { + id: string + user_id: string + redovisare: string // 12-digit org/personnummer + redovisningsperiod: string // YYYYMM + status: DeclarationStatus + kvittensnummer: string | null + signeringslank: string | null + kontrollresultat: SkatteverketKontrollresultat | null + momsuppgift: SkatteverketMomsuppgift + created_at: string + updated_at: string +} diff --git a/lib/bookkeeping/__tests__/counterparty-templates.test.ts b/lib/bookkeeping/__tests__/counterparty-templates.test.ts new file mode 100644 index 00000000..eefe0eb8 --- /dev/null +++ b/lib/bookkeeping/__tests__/counterparty-templates.test.ts @@ -0,0 +1,369 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockSupabase, + createQueuedMockSupabase, + makeTransaction, + makeCategorizationTemplate, +} from '@/tests/helpers' +import { + normalizeCounterpartyName, + calculateConfidence, + findCounterpartyTemplate, + buildMappingResultFromCounterpartyTemplate, + upsertCounterpartyTemplate, +} from '../counterparty-templates' + +describe('counterparty-templates', () => { + // ── Normalization ────────────────────────────────────────── + + describe('normalizeCounterpartyName', () => { + it('strips KORTKÖP prefix', () => { + expect(normalizeCounterpartyName('KORTKÖP ICA MAXI')).toBe('ica maxi') + }) + + it('strips SWISH prefix', () => { + expect(normalizeCounterpartyName('SWISH ANDERS JOHANSSON')).toBe('anders johansson') + }) + + it('strips BANKGIRO prefix', () => { + expect(normalizeCounterpartyName('BANKGIRO TELIA SVERIGE AB')).toBe('telia sverige') + }) + + it('strips AUTOGIRO prefix', () => { + expect(normalizeCounterpartyName('AUTOGIRO FOLKSAM')).toBe('folksam') + }) + + it('strips trailing dates (YYYYMMDD)', () => { + expect(normalizeCounterpartyName('ICA MAXI 20240615')).toBe('ica maxi') + }) + + it('strips trailing dates (YYYY-MM-DD)', () => { + expect(normalizeCounterpartyName('TELIA 2024-06-15')).toBe('telia') + }) + + it('strips inline dates', () => { + expect(normalizeCounterpartyName('SPOTIFY 20240615 PREMIUM')).toBe('spotify premium') + }) + + it('strips invoice references', () => { + expect(normalizeCounterpartyName('LEVERANTÖR F2024001')).toBe('leverantör') + }) + + it('strips trailing digit sequences (4+)', () => { + expect(normalizeCounterpartyName('CLAS OHLSON 12345')).toBe('clas ohlson') + }) + + it('strips Swedish company suffixes (AB, HB, KB)', () => { + expect(normalizeCounterpartyName('SPOTIFY AB')).toBe('spotify') + }) + + it('handles combined prefixes and dates', () => { + expect(normalizeCounterpartyName('KORTKÖP TELIA SVERIGE AB 20240615')).toBe('telia sverige') + }) + + it('preserves meaningful content', () => { + expect(normalizeCounterpartyName('HEMKÖP LINNÉ')).toBe('hemköp linné') + }) + }) + + // ── Confidence ───────────────────────────────────────────── + + describe('calculateConfidence', () => { + it('returns ~0.45 for occurrence_count = 1', () => { + const c = calculateConfidence(1) + expect(c).toBeCloseTo(0.45, 1) + }) + + it('grows logarithmically', () => { + const c1 = calculateConfidence(1) + const c5 = calculateConfidence(5) + const c10 = calculateConfidence(10) + expect(c5).toBeGreaterThan(c1) + expect(c10).toBeGreaterThan(c5) + // Growth should slow down + expect(c10 - c5).toBeLessThan(c5 - c1) + }) + + it('caps at 0.95', () => { + expect(calculateConfidence(100)).toBe(0.95) + expect(calculateConfidence(1000)).toBe(0.95) + }) + + it('never exceeds 0.95', () => { + for (let i = 1; i <= 50; i++) { + expect(calculateConfidence(i)).toBeLessThanOrEqual(0.95) + } + }) + }) + + // ── Lookup ───────────────────────────────────────────────── + + describe('findCounterpartyTemplate', () => { + it('returns null for transaction without merchant name', async () => { + const { supabase } = createMockSupabase() + const tx = makeTransaction({ merchant_name: null, description: '' }) + const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx) + expect(result).toBeNull() + }) + + it('returns exact alias match with full confidence', async () => { + const template = makeCategorizationTemplate({ confidence: 0.8 }) + const { supabase, enqueue } = createQueuedMockSupabase() + + // Alias match returns the template + enqueue({ data: template }) + + const tx = makeTransaction({ merchant_name: 'Telia Sverige AB' }) + const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx) + + expect(result).not.toBeNull() + expect(result!.matchMethod).toBe('exact_alias') + expect(result!.confidence).toBe(0.8) + }) + + it('falls through to exact normalized when alias misses', async () => { + const template = makeCategorizationTemplate({ confidence: 0.8 }) + const { supabase, enqueue } = createQueuedMockSupabase() + + enqueue({ data: null }) // Alias miss + enqueue({ data: template }) // Exact normalized hit + + const tx = makeTransaction({ merchant_name: 'Telia' }) + const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx) + + expect(result).not.toBeNull() + expect(result!.matchMethod).toBe('exact_normalized') + expect(result!.confidence).toBeCloseTo(0.76, 1) // 0.8 * 0.95 + }) + + it('falls through to fuzzy match within Levenshtein threshold', async () => { + const template = makeCategorizationTemplate({ + counterparty_name: 'telia', + confidence: 0.8, + }) + const { supabase, enqueue } = createQueuedMockSupabase() + + enqueue({ data: null }) // Alias miss + enqueue({ data: null }) // Exact miss + enqueue({ data: [template] }) // Fuzzy: all templates + + // "teliq" has Levenshtein distance 1 from "telia" + const tx = makeTransaction({ merchant_name: 'Teliq' }) + const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx) + + expect(result).not.toBeNull() + expect(result!.matchMethod).toBe('fuzzy') + expect(result!.confidence).toBeLessThan(0.8) + expect(result!.confidence).toBeGreaterThan(0) + }) + + it('returns null when fuzzy match exceeds threshold', async () => { + const template = makeCategorizationTemplate({ + counterparty_name: 'telia', + confidence: 0.8, + }) + const { supabase, enqueue } = createQueuedMockSupabase() + + enqueue({ data: null }) // Alias miss + enqueue({ data: null }) // Exact miss + enqueue({ data: [template] }) // Fuzzy: all templates + + // "xxxxx" has Levenshtein distance 5 from "telia" + const tx = makeTransaction({ merchant_name: 'XXXXX' }) + const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx) + + expect(result).toBeNull() + }) + + it('returns null when no templates exist', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + + enqueue({ data: null }) // Alias miss + enqueue({ data: null }) // Exact miss + enqueue({ data: [] }) // Fuzzy: empty + + const tx = makeTransaction({ merchant_name: 'Unknown Company' }) + const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx) + + expect(result).toBeNull() + }) + }) + + // ── Build MappingResult ──────────────────────────────────── + + describe('buildMappingResultFromCounterpartyTemplate', () => { + it('builds correct MappingResult for expense with VAT', () => { + const template = makeCategorizationTemplate({ + debit_account: '6200', + credit_account: '1930', + vat_treatment: 'standard_25', + occurrence_count: 10, + }) + const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 } + const tx = makeTransaction({ amount: -1250 }) + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma') + + expect(result.debit_account).toBe('6200') + expect(result.credit_account).toBe('1930') + expect(result.confidence).toBe(0.85) + expect(result.vat_lines.length).toBe(1) + expect(result.vat_lines[0].account_number).toBe('2641') + expect(result.vat_lines[0].debit_amount).toBeGreaterThan(0) + expect(result.rule).toBeNull() + expect(result.description).toContain('telia') + expect(result.description).toContain('10 ggr') + }) + + it('builds correct MappingResult for expense without VAT', () => { + const template = makeCategorizationTemplate({ + debit_account: '6570', + credit_account: '1930', + vat_treatment: null, + }) + const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 } + const tx = makeTransaction({ amount: -50 }) + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma') + + expect(result.debit_account).toBe('6570') + expect(result.vat_lines).toHaveLength(0) + }) + + it('builds correct MappingResult for reverse charge', () => { + const template = makeCategorizationTemplate({ + debit_account: '6540', + credit_account: '1930', + vat_treatment: 'reverse_charge', + }) + const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.8 } + const tx = makeTransaction({ amount: -5000 }) + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'aktiebolag') + + expect(result.vat_lines.length).toBe(2) + expect(result.vat_lines.some(l => l.account_number === '2645')).toBe(true) + }) + + it('does not generate VAT lines for income transactions', () => { + const template = makeCategorizationTemplate({ + debit_account: '1930', + credit_account: '3001', + vat_treatment: 'standard_25', + }) + const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.8 } + const tx = makeTransaction({ amount: 10000 }) + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma') + + expect(result.vat_lines).toHaveLength(0) + }) + + it('detects private accounts', () => { + const template = makeCategorizationTemplate({ + debit_account: '2013', + credit_account: '1930', + }) + const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.8 } + const tx = makeTransaction({ amount: -500 }) + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma') + + expect(result.default_private).toBe(true) + }) + }) + + // ── Upsert ───────────────────────────────────────────────── + + describe('upsertCounterpartyTemplate', () => { + const mappingResult = { + rule: null, + debit_account: '5410', + credit_account: '1930', + risk_level: 'NONE' as const, + confidence: 0.9, + requires_review: false, + default_private: false, + vat_lines: [], + description: 'Test', + } + + it('inserts new template for unknown counterparty', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const tx = makeTransaction({ merchant_name: 'New Company AB', date: '2024-06-15' }) + + enqueue({ data: null }) // No existing template + enqueue({ data: null }) // Insert succeeds + + await upsertCounterpartyTemplate( + supabase as never, 'user-1', tx, mappingResult, 'user_approved' + ) + + expect(supabase.from).toHaveBeenCalledWith('categorization_templates') + }) + + it('does not throw on insert error', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const tx = makeTransaction({ merchant_name: 'New Company AB' }) + + enqueue({ data: null }) // No existing + enqueue({ error: { message: 'constraint violation' } }) + + // Should not throw + await upsertCounterpartyTemplate( + supabase as never, 'user-1', tx, mappingResult, 'user_approved' + ) + }) + + it('updates existing template on re-approval', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const existing = makeCategorizationTemplate({ + debit_account: '5410', + credit_account: '1930', + occurrence_count: 3, + counterparty_aliases: ['ica maxi'], + }) + const tx = makeTransaction({ merchant_name: 'ICA Maxi', date: '2024-07-01' }) + + enqueue({ data: existing }) // Existing found + enqueue({ data: null }) // Update succeeds + + await upsertCounterpartyTemplate( + supabase as never, 'user-1', tx, mappingResult, 'user_approved' + ) + + expect(supabase.from).toHaveBeenCalledWith('categorization_templates') + }) + + it('resets occurrence_count on correction', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const existing = makeCategorizationTemplate({ + debit_account: '6200', // Different from mappingResult's 5410 + credit_account: '1930', + occurrence_count: 10, + confidence: 0.85, + }) + const tx = makeTransaction({ merchant_name: 'Telia Sverige AB', date: '2024-07-01' }) + + enqueue({ data: existing }) // Existing found (different accounts = correction) + enqueue({ data: null }) // Update succeeds + + await upsertCounterpartyTemplate( + supabase as never, 'user-1', tx, mappingResult, 'user_approved' + ) + + expect(supabase.from).toHaveBeenCalledWith('categorization_templates') + }) + + it('skips upsert for transactions without merchant name', async () => { + const { supabase } = createQueuedMockSupabase() + const tx = makeTransaction({ merchant_name: null, description: '' }) + + await upsertCounterpartyTemplate( + supabase as never, 'user-1', tx, mappingResult, 'user_approved' + ) + + expect(supabase.from).not.toHaveBeenCalled() + }) + }) +}) diff --git a/lib/bookkeeping/__tests__/mapping-engine.test.ts b/lib/bookkeeping/__tests__/mapping-engine.test.ts index d9ad1545..1e6863e1 100644 --- a/lib/bookkeeping/__tests__/mapping-engine.test.ts +++ b/lib/bookkeeping/__tests__/mapping-engine.test.ts @@ -10,6 +10,12 @@ vi.mock('../booking-templates', () => ({ buildMappingResultFromTemplate: vi.fn(), })) +// Mock counterparty-templates (needed by evaluateMappingRules) +vi.mock('../counterparty-templates', () => ({ + findCounterpartyTemplate: vi.fn().mockResolvedValue(null), + buildMappingResultFromCounterpartyTemplate: vi.fn(), +})) + describe('mapping-engine', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/lib/bookkeeping/counterparty-templates.ts b/lib/bookkeeping/counterparty-templates.ts new file mode 100644 index 00000000..15923de3 --- /dev/null +++ b/lib/bookkeeping/counterparty-templates.ts @@ -0,0 +1,355 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { + normalizeMerchantName, + levenshteinDistance, +} from '@/lib/documents/core-receipt-matcher' +import { + generateInputVatLine, + generateReverseChargeLines, + getVatRate, +} from './vat-entries' +import type { + CategorizationTemplate, + CategorizationTemplateSource, + EntityType, + MappingResult, + Transaction, + VatJournalLine, + VatTreatment, +} from '@/types' + +// ── Normalization ────────────────────────────────────────────── + +/** + * Normalize a transaction description to a canonical counterparty name. + * + * Strips bank transfer prefixes, trailing dates, invoice references, + * and trailing digit sequences, then delegates to normalizeMerchantName() + * for Swedish company suffix removal and lowercasing. + */ +export function normalizeCounterpartyName(raw: string): string { + const cleaned = raw + // Strip common bank transfer prefixes + .replace(/^(BANKGIRO|SWISH|KORTKÖP|KORT\s*KÖP|PG|BG|AUTOGIRO|PLUSGIRO)\s*/i, '') + // Strip dates (20240615, 2024-06-15, 24-06-15) + .replace(/\b\d{2,4}[-/]?\d{2}[-/]?\d{2}\b/g, '') + // Strip invoice/reference numbers (F2024-001, #12345, INV-123) + .replace(/\b[F#]?\d{4,}\S*/gi, '') + .replace(/\bINV[-]?\d+/gi, '') + // Strip trailing sequences of 4+ digits (card numbers, transaction refs) + .replace(/\s+\d{4,}\s*$/g, '') + .trim() + + return normalizeMerchantName(cleaned) +} + +// ── Confidence ───────────────────────────────────────────────── + +/** + * Logarithmic confidence formula. + * Starts low, grows slowly, caps at 0.95. Early corrections are cheap, + * later corrections are appropriately alarming. + */ +export function calculateConfidence(occurrenceCount: number): number { + const raw = 0.3 + Math.log2(occurrenceCount + 1) * 0.15 + return Math.round(Math.min(raw, 0.95) * 100) / 100 +} + +// ── Lookup ───────────────────────────────────────────────────── + +export interface CounterpartyTemplateMatch { + template: CategorizationTemplate + matchMethod: 'exact_alias' | 'exact_normalized' | 'fuzzy' + confidence: number +} + +/** + * Find a counterparty template matching a transaction. + * + * Three-tier matching: + * 1. Exact alias match — GIN index on counterparty_aliases array + * 2. Exact normalized name match — UNIQUE index + * 3. Fuzzy Levenshtein — distance ≤2 for short names, ≤3 for long names + */ +export async function findCounterpartyTemplate( + supabase: SupabaseClient, + userId: string, + transaction: Transaction +): Promise { + const rawName = transaction.merchant_name || transaction.description + if (!rawName) return null + + const normalized = normalizeCounterpartyName(rawName) + if (!normalized || normalized.length < 2) return null + + // 1. Exact alias match (highest confidence multiplier) + const { data: aliasMatch } = await supabase + .from('categorization_templates') + .select('*') + .eq('user_id', userId) + .eq('is_active', true) + .contains('counterparty_aliases', [rawName.toLowerCase()]) + .limit(1) + .maybeSingle() + + if (aliasMatch) { + return { + template: aliasMatch as CategorizationTemplate, + matchMethod: 'exact_alias', + confidence: Math.min(Number(aliasMatch.confidence) * 1.0, 1), + } + } + + // 2. Exact normalized name match + const { data: exactMatch } = await supabase + .from('categorization_templates') + .select('*') + .eq('user_id', userId) + .eq('is_active', true) + .eq('counterparty_name', normalized) + .maybeSingle() + + if (exactMatch) { + return { + template: exactMatch as CategorizationTemplate, + matchMethod: 'exact_normalized', + confidence: Math.round(Number(exactMatch.confidence) * 0.95 * 100) / 100, + } + } + + // 3. Fuzzy match — fetch all active templates, compute Levenshtein + const { data: allTemplates } = await supabase + .from('categorization_templates') + .select('*') + .eq('user_id', userId) + .eq('is_active', true) + + if (!allTemplates || allTemplates.length === 0) return null + + let bestMatch: CategorizationTemplate | null = null + let bestDistance = Infinity + + for (const tmpl of allTemplates) { + const dist = levenshteinDistance(normalized, tmpl.counterparty_name) + const maxAllowed = normalized.length <= 10 ? 2 : 3 + if (dist <= maxAllowed && dist < bestDistance) { + bestDistance = dist + bestMatch = tmpl as CategorizationTemplate + } + } + + if (bestMatch) { + const similarity = 1 - bestDistance / Math.max(normalized.length, bestMatch.counterparty_name.length) + return { + template: bestMatch, + matchMethod: 'fuzzy', + confidence: Math.round(Number(bestMatch.confidence) * similarity * 100) / 100, + } + } + + return null +} + +// ── Build MappingResult ──────────────────────────────────────── + +/** + * Convert a counterparty template match into a MappingResult + * (same shape the mapping engine expects). + */ +export function buildMappingResultFromCounterpartyTemplate( + match: CounterpartyTemplateMatch, + transaction: Transaction, + entityType: EntityType +): MappingResult { + const tmpl = match.template + const absAmount = Math.abs(transaction.amount) + const isExpense = transaction.amount < 0 + + // Generate VAT lines if applicable + const vatLines: VatJournalLine[] = [] + if (isExpense && tmpl.vat_treatment) { + const vatTreatment = tmpl.vat_treatment as VatTreatment + if (vatTreatment === 'reverse_charge') { + const rcLines = generateReverseChargeLines(absAmount) + for (const rcl of rcLines) { + vatLines.push({ + account_number: rcl.account_number, + debit_amount: rcl.debit_amount, + credit_amount: rcl.credit_amount, + description: rcl.line_description || '', + }) + } + } else { + const vatRate = getVatRate(vatTreatment) + if (vatRate > 0) { + const vatLine = generateInputVatLine(absAmount, vatRate) + if (vatLine) { + vatLines.push({ + account_number: vatLine.account_number, + debit_amount: vatLine.debit_amount, + credit_amount: vatLine.credit_amount, + description: vatLine.line_description || '', + }) + } + } + } + } + + // Determine if private (debit to private account means non-business) + const privateAccounts = ['2013', '2893'] + const isPrivate = privateAccounts.includes(tmpl.debit_account) + + return { + rule: null, + debit_account: tmpl.debit_account, + credit_account: tmpl.credit_account, + risk_level: 'NONE', + confidence: match.confidence, + requires_review: false, + default_private: isPrivate, + vat_lines: vatLines, + description: `Motpart: ${tmpl.counterparty_name} (${tmpl.occurrence_count} ggr)`, + } +} + +// ── Feedback / Upsert ────────────────────────────────────────── + +/** + * Upsert a counterparty template from a categorization result. + * + * - New counterparty: insert with starting confidence based on source + * - Re-approval (same accounts): increment occurrence_count, recalc confidence + * - Correction (different accounts): update accounts, reset occurrence_count to 1 + * - Always: add raw name alias if not present, update last_seen_date + */ +export async function upsertCounterpartyTemplate( + supabase: SupabaseClient, + userId: string, + transaction: Transaction, + mappingResult: MappingResult, + source: CategorizationTemplateSource +): Promise { + const rawName = transaction.merchant_name || transaction.description + if (!rawName) return + + const normalized = normalizeCounterpartyName(rawName) + if (!normalized || normalized.length < 2) return + + const rawNameLower = rawName.toLowerCase() + const category = transaction.category !== 'uncategorized' ? transaction.category : null + const txDate = transaction.date + + // Check for existing template + const { data: existing } = await supabase + .from('categorization_templates') + .select('*') + .eq('user_id', userId) + .eq('counterparty_name', normalized) + .maybeSingle() + + if (existing) { + const isCorrection = + existing.debit_account !== mappingResult.debit_account || + existing.credit_account !== mappingResult.credit_account + + // Build updated aliases array (add raw name if not present) + const aliases: string[] = existing.counterparty_aliases || [] + if (!aliases.includes(rawNameLower)) { + aliases.push(rawNameLower) + } + + if (isCorrection) { + // Correction: update accounts, reset occurrence to 1 + const newConfidence = calculateConfidence(1) + await supabase + .from('categorization_templates') + .update({ + debit_account: mappingResult.debit_account, + credit_account: mappingResult.credit_account, + vat_treatment: mappingResult.vat_lines.length > 0 + ? detectVatTreatment(mappingResult) + : existing.vat_treatment, + vat_account: mappingResult.vat_lines[0]?.account_number || existing.vat_account, + category: category || existing.category, + occurrence_count: 1, + confidence: newConfidence, + last_seen_date: txDate, + source: source === 'auto_learned' ? existing.source : source, + counterparty_aliases: aliases, + }) + .eq('id', existing.id) + } else { + // Re-approval: increment count, recalc confidence + const newCount = existing.occurrence_count + 1 + const newConfidence = calculateConfidence(newCount) + // Upgrade source if human approves an auto-learned template + const newSource = + source === 'user_approved' && existing.source === 'auto_learned' + ? 'user_approved' + : existing.source + + await supabase + .from('categorization_templates') + .update({ + occurrence_count: newCount, + confidence: newConfidence, + last_seen_date: txDate, + source: newSource, + counterparty_aliases: aliases, + category: category || existing.category, + }) + .eq('id', existing.id) + } + } else { + // New template + const startingConfidence = source === 'auto_learned' + ? calculateConfidence(1) // ~0.45 + : calculateConfidence(1) // same formula, but source flag matters for threshold + + await supabase + .from('categorization_templates') + .insert({ + user_id: userId, + counterparty_name: normalized, + counterparty_aliases: [rawNameLower], + debit_account: mappingResult.debit_account, + credit_account: mappingResult.credit_account, + vat_treatment: detectVatTreatment(mappingResult), + vat_account: mappingResult.vat_lines[0]?.account_number || null, + category: category || null, + occurrence_count: 1, + confidence: startingConfidence, + last_seen_date: txDate, + source, + }) + } +} + +/** + * Detect VAT treatment from a MappingResult's VAT lines. + */ +function detectVatTreatment(result: MappingResult): string | null { + if (result.vat_lines.length === 0) return null + + // Check for reverse charge (2645 debit = fiktiv ingående) + const hasReverseCharge = result.vat_lines.some( + (l) => l.account_number === '2645' + ) + if (hasReverseCharge) return 'reverse_charge' + + // Check for input VAT (2641 debit) + const inputVat = result.vat_lines.find( + (l) => l.account_number === '2641' && l.debit_amount > 0 + ) + if (!inputVat) return null + + // Derive rate from the line description (generated by generateInputVatLine) + // Format: "Ingående moms 25%", "Ingående moms 12%", "Ingående moms 6%" + const rateMatch = inputVat.description?.match(/(\d+)%/) + if (rateMatch) { + const pct = parseInt(rateMatch[1], 10) + if (pct === 12) return 'reduced_12' + if (pct === 6) return 'reduced_6' + } + return 'standard_25' +} diff --git a/lib/bookkeeping/mapping-engine.ts b/lib/bookkeeping/mapping-engine.ts index 584ac5f0..7c7c405c 100644 --- a/lib/bookkeeping/mapping-engine.ts +++ b/lib/bookkeeping/mapping-engine.ts @@ -4,6 +4,10 @@ import { generateReverseChargeLines, } from './vat-entries' import { findMatchingTemplates, buildMappingResultFromTemplate } from './booking-templates' +import { + findCounterpartyTemplate, + buildMappingResultFromCounterpartyTemplate, +} from './counterparty-templates' import type { MappingRule, MappingResult, @@ -38,7 +42,9 @@ function getCapitalizationThreshold(year: number): number { * 2. MCC code rules (priority 50-69) * 3. Merchant name pattern rules (priority 70-89) * 4. Amount threshold rules (priority 90-99) - * 5. Risk fallback (priority 100) → 2013 (private) + * 5. Counterparty templates (learned from history, fuzzy matching) + * 6. Static booking templates (keyword/MCC matching) + * 7. Default fallback (uncategorized) */ export async function evaluateMappingRules( supabase: SupabaseClient, @@ -55,7 +61,10 @@ export async function evaluateMappingRules( .order('priority', { ascending: true }) if (error || !rules || rules.length === 0) { - // Try template-based matching before default fallback + // Try counterparty templates before static template fallback + const counterpartyResult = await evaluateCounterpartyTemplates(supabase, userId, transaction, entityType) + if (counterpartyResult) return counterpartyResult + const templateResult = evaluateTemplateRules(transaction, entityType) if (templateResult) return templateResult return getDefaultResult(transaction) @@ -68,6 +77,10 @@ export async function evaluateMappingRules( } } + // Try counterparty templates before static template fallback + const counterpartyResult = await evaluateCounterpartyTemplates(supabase, userId, transaction, entityType) + if (counterpartyResult) return counterpartyResult + // Try template-based matching before default fallback const templateResult = evaluateTemplateRules(transaction, entityType) if (templateResult) return templateResult @@ -97,6 +110,35 @@ function evaluateTemplateRules( return result } +/** + * Evaluate counterparty templates as a fallback when no DB mapping rule matches. + * Source-aware threshold: auto_learned needs 0.6 (require more evidence), + * user_approved/sie_import use 0.4 (human has validated the pattern). + */ +async function evaluateCounterpartyTemplates( + supabase: SupabaseClient, + userId: string, + transaction: Transaction, + entityType?: EntityType +): Promise { + try { + const match = await findCounterpartyTemplate(supabase, userId, transaction) + if (!match) return null + + const threshold = match.template.source === 'auto_learned' ? 0.6 : 0.4 + if (match.confidence < threshold) return null + + return buildMappingResultFromCounterpartyTemplate( + match, + transaction, + entityType || 'enskild_firma' + ) + } catch { + // Non-critical — fall through to next fallback + return null + } +} + /** * Check if a transaction matches a mapping rule */ diff --git a/lib/extensions/__tests__/sectors.test.ts b/lib/extensions/__tests__/sectors.test.ts index d5a1b70b..2bfd9aa9 100644 --- a/lib/extensions/__tests__/sectors.test.ts +++ b/lib/extensions/__tests__/sectors.test.ts @@ -49,7 +49,7 @@ describe('sectors registry', () => { }) it('should have 8 total extensions', () => { - expect(getAllExtensions().length).toBe(11) + expect(getAllExtensions().length).toBe(12) }) it('should have unique slugs within each sector', () => { @@ -94,7 +94,7 @@ describe('sectors registry', () => { it('getExtensionsBySector returns extensions for a sector', () => { const extensions = getExtensionsBySector('general') - expect(extensions.length).toBe(11) + expect(extensions.length).toBe(12) }) it('all extensions have required fields', () => { diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index ff689973..b81c25d9 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -93,6 +93,7 @@ describe('formatPeriodLabel', () => { describe('getVatDeclarationSummary', () => { const emptyRc = { ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0, ruta30: 0, ruta31: 0, ruta32: 0 } + const zeroExtras = { ruta08: 0, ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0, ruta41: 0, ruta42: 0, ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0 } it('calculates totals and detects payment', () => { const declaration: VatDeclaration = { @@ -104,6 +105,7 @@ describe('getVatDeclarationSummary', () => { ruta30: 0, ruta31: 0, ruta32: 0, ruta39: 0, ruta40: 0, ruta48: 1000, ruta49: 1500, + ...zeroExtras, }, invoiceCount: 5, transactionCount: 10, @@ -132,6 +134,7 @@ describe('getVatDeclarationSummary', () => { ruta30: 0, ruta31: 0, ruta32: 0, ruta39: 0, ruta40: 0, ruta48: 3000, ruta49: -2500, + ...zeroExtras, }, invoiceCount: 1, transactionCount: 20, @@ -158,6 +161,7 @@ describe('getVatDeclarationSummary', () => { ruta30: 1250, ruta31: 0, ruta32: 0, ruta39: 0, ruta40: 0, ruta48: 2250, ruta49: 1500, + ...zeroExtras, }, invoiceCount: 2, transactionCount: 0, diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index 4f20347b..3560c982 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -164,12 +164,14 @@ export async function calculateVatDeclaration( // Map account balances to momsdeklaration boxes const rutor: VatDeclarationRutor = { - ruta05: 0, ruta06: 0, ruta07: 0, + ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0, ruta10: 0, ruta11: 0, ruta12: 0, ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0, ruta30: 0, ruta31: 0, ruta32: 0, - ruta39: 0, ruta40: 0, + ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0, + ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0, ruta48: 0, ruta49: 0, + ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0, } for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) { @@ -181,9 +183,11 @@ export async function calculateVatDeclaration( rutor[mapping.box] = round(rutor[mapping.box] + balance) } + // FK009: summaMoms = (10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - 48 rutor.ruta49 = round( rutor.ruta10 + rutor.ruta11 + rutor.ruta12 + - rutor.ruta30 + rutor.ruta31 + rutor.ruta32 - + rutor.ruta30 + rutor.ruta31 + rutor.ruta32 + + rutor.ruta60 + rutor.ruta61 + rutor.ruta62 - rutor.ruta48 ) @@ -370,7 +374,10 @@ export function getVatDeclarationSummary(declaration: VatDeclaration): { declaration.rutor.ruta12 + declaration.rutor.ruta30 + declaration.rutor.ruta31 + - declaration.rutor.ruta32 + declaration.rutor.ruta32 + + declaration.rutor.ruta60 + + declaration.rutor.ruta61 + + declaration.rutor.ruta62 ) const totalInputVat = declaration.rutor.ruta48 diff --git a/lib/skatteverket/format.ts b/lib/skatteverket/format.ts new file mode 100644 index 00000000..6faec1ca --- /dev/null +++ b/lib/skatteverket/format.ts @@ -0,0 +1,60 @@ +import type { VatPeriodType } from '@/types' + +/** + * Convert a gnubok org_number to Skatteverket's 12-digit "redovisare" format. + * + * Rules: + * - Organisationsnummer (10 digits, e.g. 5020000013): prefix with "16" → 165020000013 + * - Personnummer (10 digits, e.g. 8501011234): prefix with "19" or "20" based on century + * - Strip any hyphens before processing + */ +export function formatRedovisare( + orgNumber: string, + entityType: 'enskild_firma' | 'aktiebolag' +): string { + const clean = orgNumber.replace(/-/g, '') + + if (clean.length === 12) return clean + + if (clean.length !== 10) { + throw new Error(`Ogiltigt organisationsnummer: ${orgNumber} (förväntar 10 eller 12 siffror)`) + } + + if (entityType === 'aktiebolag') return `16${clean}` + + // Enskild firma — personnummer + const yearDigits = parseInt(clean.substring(0, 2), 10) + const currentTwoDigitYear = new Date().getFullYear() % 100 + const prefix = yearDigits > currentTwoDigitYear ? '19' : '20' + return `${prefix}${clean}` +} + +/** + * Convert gnubok period parameters to Skatteverket's YYYYMM format. + * + * Skatteverket expects the last month of the period. + * - monthly period 3, year 2025 → "202503" + * - quarterly period 1, year 2025 → "202503" (Q1 ends in March) + * - yearly period 1, year 2025 → "202512" + */ +export function formatRedovisningsperiod( + periodType: VatPeriodType, + year: number, + period: number +): string { + let lastMonth: number + + switch (periodType) { + case 'monthly': + lastMonth = period + break + case 'quarterly': + lastMonth = period * 3 + break + case 'yearly': + lastMonth = 12 + break + } + + return `${year}${String(lastMonth).padStart(2, '0')}` +} diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index 4b5918b5..90d6349a 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' +import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching' import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching' import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation' @@ -336,6 +337,16 @@ export async function ingestTransactions( }) .eq('id', newTransaction.id) + // Upsert counterparty template (auto-learned, lower confidence) + try { + await upsertCounterpartyTemplate( + supabase, userId, newTransaction as Transaction, + mappingResult, 'auto_learned' + ) + } catch { + // Non-critical + } + result.auto_categorized++ } } diff --git a/supabase/migrations/20260324120000_categorization_templates.sql b/supabase/migrations/20260324120000_categorization_templates.sql new file mode 100644 index 00000000..b654f2f1 --- /dev/null +++ b/supabase/migrations/20260324120000_categorization_templates.sql @@ -0,0 +1,66 @@ +-- Migration: categorization_templates +-- Per-tenant counterparty-based categorization templates. +-- Learned from user categorizations, SIE imports, or SNI defaults. +-- Slots into the categorization chain between mapping_rules and static booking templates. + +CREATE TABLE public.categorization_templates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL, + + -- Counterparty identity + counterparty_name TEXT NOT NULL, -- normalized canonical name + counterparty_aliases TEXT[] NOT NULL DEFAULT '{}', -- raw name variants seen + + -- Accounting mapping + debit_account TEXT NOT NULL, -- BAS account number (string) + credit_account TEXT NOT NULL, -- BAS account number (string) + vat_treatment TEXT, -- standard_25, reduced_12, etc. + vat_account TEXT, -- 2611/2621/2631/2641 + category TEXT, -- TransactionCategory for reverse lookup + + -- Confidence signals + occurrence_count INTEGER NOT NULL DEFAULT 1, + confidence NUMERIC NOT NULL DEFAULT 0.5 + CHECK (confidence >= 0 AND confidence <= 1), + last_seen_date DATE, + + -- Source tracking + source TEXT NOT NULL DEFAULT 'user_approved' + CHECK (source IN ('sie_import', 'user_approved', 'sni_default', 'auto_learned')), + + -- Metadata + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- One template per counterparty per user (upsert-friendly) + UNIQUE (user_id, counterparty_name) +); + +-- RLS +ALTER TABLE public.categorization_templates ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "categorization_templates_select" ON public.categorization_templates + FOR SELECT USING (auth.uid() = user_id); +CREATE POLICY "categorization_templates_insert" ON public.categorization_templates + FOR INSERT WITH CHECK (auth.uid() = user_id); +CREATE POLICY "categorization_templates_update" ON public.categorization_templates + FOR UPDATE USING (auth.uid() = user_id); +CREATE POLICY "categorization_templates_delete" ON public.categorization_templates + FOR DELETE USING (auth.uid() = user_id); + +-- Indexes +CREATE INDEX idx_categorization_templates_user_id + ON public.categorization_templates (user_id); +CREATE INDEX idx_categorization_templates_counterparty + ON public.categorization_templates (user_id, counterparty_name); +CREATE INDEX idx_categorization_templates_aliases + ON public.categorization_templates USING GIN (counterparty_aliases); +CREATE INDEX idx_categorization_templates_active + ON public.categorization_templates (user_id, is_active) + WHERE is_active = TRUE; + +-- updated_at trigger +CREATE TRIGGER categorization_templates_updated_at + BEFORE UPDATE ON public.categorization_templates + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/supabase/migrations/20260324120001_skatteverket_tokens.sql b/supabase/migrations/20260324120001_skatteverket_tokens.sql new file mode 100644 index 00000000..ec3fcbaf --- /dev/null +++ b/supabase/migrations/20260324120001_skatteverket_tokens.sql @@ -0,0 +1,43 @@ +-- Skatteverket OAuth2 token storage +-- +-- Stores encrypted access/refresh tokens from the Skatteverket +-- `per` (BankID) OAuth2 flow. One row per user. +-- Tokens are AES-256-GCM encrypted at the application layer. + +CREATE TABLE public.skatteverket_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL, + access_token TEXT NOT NULL, -- AES-256-GCM encrypted + refresh_token TEXT, -- AES-256-GCM encrypted + expires_at TIMESTAMPTZ NOT NULL, + refresh_count INTEGER NOT NULL DEFAULT 0, + scope TEXT NOT NULL DEFAULT 'momsdeklaration', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id) +); + +-- RLS +ALTER TABLE public.skatteverket_tokens ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view own Skatteverket tokens" + ON public.skatteverket_tokens FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own Skatteverket tokens" + ON public.skatteverket_tokens FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own Skatteverket tokens" + ON public.skatteverket_tokens FOR UPDATE + USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete own Skatteverket tokens" + ON public.skatteverket_tokens FOR DELETE + USING (auth.uid() = user_id); + +-- updated_at trigger +CREATE TRIGGER update_skatteverket_tokens_updated_at + BEFORE UPDATE ON public.skatteverket_tokens + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/tests/helpers.ts b/tests/helpers.ts index 7c4d7b84..5ed3e676 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -17,6 +17,7 @@ import type { SupplierInvoice, CompanySettings, InvoiceInboxItem, + CategorizationTemplate, } from '@/types' // ============================================================ @@ -645,3 +646,27 @@ export function createQueuedMockSupabase() { return { supabase, enqueue, enqueueMany, reset } } + +export function makeCategorizationTemplate( + overrides: Partial = {} +): CategorizationTemplate { + return { + id: nextId(), + user_id: 'user-1', + counterparty_name: 'telia', + counterparty_aliases: ['telia sverige ab'], + debit_account: '6200', + credit_account: '1930', + vat_treatment: 'standard_25', + vat_account: '2641', + category: 'expense_telecom', + occurrence_count: 5, + confidence: 0.7, + last_seen_date: '2024-06-15', + source: 'user_approved', + is_active: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-06-15T00:00:00Z', + ...overrides, + } +} diff --git a/types/index.ts b/types/index.ts index 942b25e4..54618af6 100644 --- a/types/index.ts +++ b/types/index.ts @@ -937,6 +937,29 @@ export interface VatJournalLine { description: string } +// Categorization template source +export type CategorizationTemplateSource = 'sie_import' | 'user_approved' | 'sni_default' | 'auto_learned' + +// Per-tenant counterparty-based categorization template +export interface CategorizationTemplate { + id: string + user_id: string + counterparty_name: string + counterparty_aliases: string[] + debit_account: string + credit_account: string + vat_treatment: VatTreatment | null + vat_account: string | null + category: TransactionCategory | null + occurrence_count: number + confidence: number + last_seen_date: string | null + source: CategorizationTemplateSource + is_active: boolean + created_at: string + updated_at: string +} + // Account Balance (cached) export interface AccountBalance { id: string @@ -1561,11 +1584,13 @@ export const RECEIPT_STATUS_LABELS: Record = { export type VatPeriodType = 'monthly' | 'quarterly' | 'yearly' // VAT declaration rutor (boxes) according to SKV 4700 +// Complete set of all 30 boxes in the momsdeklaration form. export interface VatDeclarationRutor { // 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) + ruta06: number // Momspliktiga uttag (always 0 for most users) + ruta07: number // Vinstmarginalbeskattning (always 0 for most users) + ruta08: number // Hyresinkomster frivillig beskattning (always 0 for most users) // Utgående moms (Output VAT per rate) ruta10: number // Utgående moms 25% @@ -1573,10 +1598,10 @@ export interface VatDeclarationRutor { ruta12: number // Utgående moms 6% // Inköp vid omvänd skattskyldighet (reverse charge purchase bases) - ruta20: number // Inköp av varor från annat EU-land (unused, goods via Tullverket) + ruta20: number // Inköp av varor från annat EU-land ruta21: number // Inköp av tjänster från annat EU-land ruta22: number // Inköp av tjänster från land utanför EU - ruta23: number // Inköp av varor i Sverige (unused, construction reverse charge goods) + ruta23: number // Inköp av varor i Sverige (construction reverse charge goods) ruta24: number // Övriga inköp av tjänster i Sverige (domestic reverse charge) // Utgående moms omvänd skattskyldighet (self-assessed output VAT on reverse charge) @@ -1584,15 +1609,27 @@ export interface VatDeclarationRutor { ruta31: number // Utgående moms 12% omvänd skattskyldighet ruta32: number // Utgående moms 6% omvänd skattskyldighet - // EU och export + // EU och export försäljning + ruta35: number // Varuförsäljning till annat EU-land + ruta36: number // Varuförsäljning utanför EU (export) + ruta37: number // Mellanmans inköp vid trepartshandel + ruta38: number // Mellanmans försäljning vid trepartshandel ruta39: number // Försäljning av tjänster till annat EU-land (reverse charge) - ruta40: number // Export utanför EU + ruta40: number // Övrig försäljning av tjänster utomlands + ruta41: number // Försäljning med omvänd skattskyldighet (Sverige) + ruta42: number // Övrig momsfri försäljning m.m. // Ingående moms (Input VAT) ruta48: number // Ingående moms att dra av // Moms att betala eller få tillbaka ruta49: number // Moms att betala (positive) eller återfå (negative) + + // Import (via Tullverket) + ruta50: number // Beskattningsunderlag vid import + ruta60: number // Utgående moms 25% import + ruta61: number // Utgående moms 12% import + ruta62: number // Utgående moms 6% import } // VAT declaration response @@ -1655,6 +1692,7 @@ export const VAT_RUTA_LABELS: Record = { ruta05: 'Momspliktig försäljning', ruta06: 'Momspliktiga uttag', ruta07: 'Vinstmarginalbeskattning', + ruta08: 'Hyresinkomster (frivillig beskattning)', ruta10: 'Utgående moms 25%', ruta11: 'Utgående moms 12%', ruta12: 'Utgående moms 6%', @@ -1666,10 +1704,20 @@ export const VAT_RUTA_LABELS: Record = { ruta30: 'Utgående moms 25% (omvänd skattskyldighet)', ruta31: 'Utgående moms 12% (omvänd skattskyldighet)', ruta32: 'Utgående moms 6% (omvänd skattskyldighet)', + ruta35: 'Varuförsäljning till annat EU-land', + ruta36: 'Varuförsäljning utanför EU (export)', + ruta37: 'Mellanmans inköp vid trepartshandel', + ruta38: 'Mellanmans försäljning vid trepartshandel', ruta39: 'Försäljning av tjänster till EU-land', - ruta40: 'Export utanför EU', + ruta40: 'Övrig försäljning av tjänster utomlands', + ruta41: 'Försäljning med omvänd skattskyldighet (Sverige)', + ruta42: 'Övrig momsfri försäljning m.m.', ruta48: 'Ingående moms att dra av', ruta49: 'Moms att betala/återfå', + ruta50: 'Beskattningsunderlag vid import', + ruta60: 'Utgående moms 25% import', + ruta61: 'Utgående moms 12% import', + ruta62: 'Utgående moms 6% import', } // ============================================================