feat: counterparty templates, Skatteverket extension, complete VAT form (#117)
* feat: separate AR/AP/accounting into distinct nav groups (#92) Split the flat "Finans" sidebar group into three visually distinct sections — Försäljning (AR), Inköp (AP), and Redovisning — so users coming from Fortnox immediately find customer invoicing and supplier invoices as top-level concepts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: journal entry detail view, correction chain, and account name display - Add journal entry detail page at /bookkeeping/[id] with full entry view - Add correction chain API and component showing storno relationships - Add JournalEntryStatusBadge component for entry status display - Show debit/credit account names in template picker and review dialogs - Expand client-side BAS account name mapping with additional accounts - Show account codes on transaction inbox suggestion buttons Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback — N+1 query, duplicate name, nav dedup - Batch reverse-lookup into single query per BFS iteration (was N+1) - Differentiate account 2393 from 2893 in display names - Extract shared loop for desktop/mobile nav group rendering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: counterparty templates, Skatteverket extension, VAT form completeness, and UI cleanup - Add counterparty-based categorization templates (learned from user approvals and auto-ingestion) with fuzzy matching in the mapping engine - Add Skatteverket extension for direct VAT declaration submission via API - Complete VAT declaration form with all 30 SKV 4700 boxes (ruta 08, 35-42, 50, 60-62) - Fix ruta 49 formula to include import VAT (ruta 60+61+62) - Simplify dashboard UI: remove redundant icons from stat cards, customer cards, invoice list, supplier invoices; use Badge variants consistently - Add SkatteverketPanel component to reports page - Add categorization_templates and skatteverket_tokens migrations - Update tests and helpers for new types Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review feedback — VAT detection, migration timestamps, dedup - Fix detectVatTreatment to derive actual rate (12%/6%) from VAT line description instead of hardcoding standard_25 - Rename skatteverket_tokens migration to 20260324120001 to avoid duplicate timestamp with categorization_templates (fixes Supabase deployment failure) - Make refreshAccessToken accept previousRefreshCount param to enforce refresh limit contract at the type level - Fix rate limiter TOCTOU by claiming slot before await - Extract formatRedovisare/formatRedovisningsperiod to shared lib/skatteverket/format.ts — eliminates duplication between mappers.ts and SkatteverketPanel.tsx Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3050743013
commit
d8e0a22495
@@ -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.
|
||||
|
||||
|
||||
@@ -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<CustomerType, string> = {
|
||||
non_eu_business: 'Utanför EU',
|
||||
}
|
||||
|
||||
const customerTypeColors: Record<CustomerType, string> = {
|
||||
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() {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base truncate group-hover:text-primary transition-colors">{customer.name}</CardTitle>
|
||||
<span className={cn(
|
||||
'inline-block mt-1 text-xs font-medium px-2 py-0.5 rounded-full border',
|
||||
customerTypeColors[customer.customer_type]
|
||||
)}>
|
||||
<Badge variant="secondary" className="mt-1">
|
||||
{customerTypeLabels[customer.customer_type]}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<div className="space-y-1.5 text-sm text-muted-foreground">
|
||||
{customer.email && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{customer.email}</span>
|
||||
</div>
|
||||
<p className="truncate">{customer.email}</p>
|
||||
)}
|
||||
{customer.org_number && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{customer.org_number}</span>
|
||||
{customer.vat_number_validated && (
|
||||
<Badge variant="success" className="text-xs">Verifierad</Badge>
|
||||
@@ -214,10 +199,7 @@ export default function CustomersPage() {
|
||||
</div>
|
||||
)}
|
||||
{customer.city && (
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{customer.city}, {customer.country}</span>
|
||||
</div>
|
||||
<p>{customer.city}, {customer.country}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -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<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; icon: React.ElementType }> = {
|
||||
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<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
|
||||
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
|
||||
<Badge variant="secondary" className="bg-success/10 text-success">Följesedel</Badge>
|
||||
)}
|
||||
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{status.label}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -436,10 +430,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
{/* Customer info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5" />
|
||||
Kund
|
||||
</CardTitle>
|
||||
<CardTitle>Kund</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
@@ -450,31 +441,22 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
{customer.vat_number && (
|
||||
<p className="text-muted-foreground">VAT: {customer.vat_number}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 pt-2">
|
||||
<div className="flex flex-wrap gap-4 pt-2 text-sm text-muted-foreground">
|
||||
{customer.email && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Mail className="h-4 w-4" />
|
||||
{customer.email}
|
||||
</div>
|
||||
<span>{customer.email}</span>
|
||||
)}
|
||||
{customer.phone && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Phone className="h-4 w-4" />
|
||||
{customer.phone}
|
||||
</div>
|
||||
<span>{customer.phone}</span>
|
||||
)}
|
||||
</div>
|
||||
{(customer.address_line1 || customer.city) && (
|
||||
<div className="flex items-start gap-1 text-sm text-muted-foreground pt-1">
|
||||
<MapPin className="h-4 w-4 mt-0.5" />
|
||||
<div>
|
||||
{customer.address_line1 && <p>{customer.address_line1}</p>}
|
||||
{customer.address_line2 && <p>{customer.address_line2}</p>}
|
||||
<p>
|
||||
{customer.postal_code} {customer.city}
|
||||
{customer.country !== 'SE' && `, ${customer.country}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground pt-1">
|
||||
{customer.address_line1 && <p>{customer.address_line1}</p>}
|
||||
{customer.address_line2 && <p>{customer.address_line2}</p>}
|
||||
<p>
|
||||
{customer.postal_code} {customer.city}
|
||||
{customer.country !== 'SE' && `, ${customer.country}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; icon: React.ElementType; borderColor: string }> = {
|
||||
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<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; borderColor: string }> = {
|
||||
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) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-muted animate-pulse" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-20 animate-pulse" />
|
||||
<div className="h-7 bg-muted rounded w-16 animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-20 animate-pulse" />
|
||||
<div className="h-7 bg-muted rounded w-16 animate-pulse" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -141,48 +138,27 @@ export default function InvoicesPage() {
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Receipt className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Totalt antal</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{invoices.length}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Totalt antal</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{invoices.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">Obetalda</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{stats.unpaid}</p>
|
||||
{stats.overdue > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{stats.overdue} förfallna
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Obetalda</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{stats.unpaid}</p>
|
||||
{stats.overdue > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{stats.overdue} förfallna
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Send className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Att få in</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Att få in</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
@@ -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 (
|
||||
<Link key={invoice.id} href={`/invoices/${invoice.id}`}>
|
||||
@@ -272,11 +247,7 @@ export default function InvoicesPage() {
|
||||
invoice.status === 'overdue' && 'ring-1 ring-destructive/20'
|
||||
)}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3 sm:gap-4 sm:items-center">
|
||||
<div className={`h-10 w-10 rounded-lg flex items-center justify-center shrink-0 ${isCreditNote ? 'bg-destructive/10' : 'bg-muted'}`}>
|
||||
<StatusIcon className={`h-5 w-5 ${isCreditNote ? 'text-destructive' : 'text-muted-foreground'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-start sm:items-center justify-between gap-2">
|
||||
<p className="font-medium truncate">{invoice.invoice_number}</p>
|
||||
<p className={`font-medium tabular-nums shrink-0 ${isCreditNote ? 'text-destructive' : ''}`}>
|
||||
@@ -316,7 +287,6 @@ export default function InvoicesPage() {
|
||||
{formatCurrency(Number(invoice.total_sek))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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 */}
|
||||
<SkatteverketPanel
|
||||
periodType={periodType}
|
||||
year={year}
|
||||
period={period}
|
||||
hasData={data !== null}
|
||||
/>
|
||||
|
||||
{!data && !loading && !error && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
|
||||
registered: 'secondary',
|
||||
approved: 'default',
|
||||
paid: 'success',
|
||||
partially_paid: 'warning',
|
||||
overdue: 'destructive',
|
||||
disputed: 'warning',
|
||||
credited: 'secondary',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
@@ -106,8 +106,7 @@ export default function SupplierInvoicesPage() {
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<FileInput className="h-4 w-4" />
|
||||
<CardTitle className="text-sm text-muted-foreground">
|
||||
Totalt obetalt
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -120,8 +119,7 @@ export default function SupplierInvoicesPage() {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
<CardTitle className="text-sm text-muted-foreground">
|
||||
Förfallet
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -132,8 +130,7 @@ export default function SupplierInvoicesPage() {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<CardTitle className="text-sm text-muted-foreground">
|
||||
Antal fakturor
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -230,7 +227,7 @@ export default function SupplierInvoicesPage() {
|
||||
<td className="p-3 text-right font-mono">{formatAmount(inv.total)}</td>
|
||||
<td className="p-3 text-right font-mono">{formatAmount(inv.remaining_amount)}</td>
|
||||
<td className="p-3">
|
||||
<Badge className={statusColors[inv.status] || ''}>
|
||||
<Badge variant={statusVariants[inv.status] || 'secondary'}>
|
||||
{statusLabels[inv.status] || inv.status}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isBalanced ? (
|
||||
<Badge variant="secondary" className="bg-success/10 text-success gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
<Badge variant="secondary" className="bg-success/10 text-success">
|
||||
Debet = Kredit
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<Badge variant="destructive">
|
||||
Obalanserad ({formatCurrency(Math.abs(totalDebit - totalCredit))})
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -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 <SkatteverketPanelInner {...props} />
|
||||
}
|
||||
|
||||
function SkatteverketPanelInner({ periodType, year, period, hasData }: SkatteverketPanelProps) {
|
||||
const [status, setStatus] = useState<SkatteverketStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [kontroller, setKontroller] = useState<KontrollResult[]>([])
|
||||
const [signeringslank, setSigneringslank] = useState<string | null>(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<string> => {
|
||||
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 (
|
||||
<Card>
|
||||
<CardContent className="p-6 flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Kontrollerar Skatteverket-anslutning...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Not connected
|
||||
if (!status?.connected) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileCheck className="h-5 w-5" />
|
||||
Skicka till Skatteverket
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 text-sm text-destructive bg-destructive/5 rounded-lg p-3">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Anslut till Skatteverket med BankID för att skicka momsdeklarationen direkt.
|
||||
</p>
|
||||
<Button onClick={handleConnect} className="gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
Anslut med BankID
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Connected — show actions
|
||||
const hasErrors = kontroller.some(k => k.typ === 'ERROR')
|
||||
const hasWarnings = kontroller.some(k => k.typ === 'WARNING')
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileCheck className="h-5 w-5" />
|
||||
Skicka till Skatteverket
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-success border-success/30 gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Ansluten
|
||||
</Badge>
|
||||
{status.expired && (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Session utgången
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 text-sm text-destructive bg-destructive/5 rounded-lg p-3">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{success && !error && (
|
||||
<div className="flex items-start gap-2 text-sm text-success bg-success/5 rounded-lg p-3">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation results */}
|
||||
{kontroller.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Valideringsresultat
|
||||
</p>
|
||||
{kontroller.map((k, i) => (
|
||||
<div
|
||||
key={`${k.id}-${i}`}
|
||||
className={`flex items-start gap-2 text-sm rounded-lg p-2.5 ${
|
||||
k.typ === 'ERROR'
|
||||
? 'bg-destructive/5 text-destructive'
|
||||
: 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-200'
|
||||
}`}
|
||||
>
|
||||
{k.typ === 'ERROR' ? (
|
||||
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<span className="font-mono text-xs mr-1.5">{k.id}</span>
|
||||
{k.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submitted confirmation */}
|
||||
{submitted && (
|
||||
<div className="rounded-lg border p-3 space-y-1">
|
||||
<p className="text-sm font-medium flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
Inlämnad
|
||||
</p>
|
||||
{submitted.kvittensnummer && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kvittensnummer: <span className="font-mono">{submitted.kvittensnummer}</span>
|
||||
</p>
|
||||
)}
|
||||
{submitted.tidpunkt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tidpunkt: {new Date(submitted.tidpunkt).toLocaleString('sv-SE')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signing link */}
|
||||
{signeringslank && (
|
||||
<div className="rounded-lg border border-success/30 bg-success/5 p-3 space-y-2">
|
||||
<p className="text-sm font-medium">Utkastet är låst och redo att signeras</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Öppna länken nedan och signera med BankID på Skatteverkets sida.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => window.open(signeringslank, '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Öppna signeringssidan
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleValidate}
|
||||
disabled={!hasData || actionLoading !== null}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{actionLoading === 'validate' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileCheck className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Validera
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={!hasData || actionLoading !== null}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{actionLoading === 'draft' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Spara utkast
|
||||
</Button>
|
||||
|
||||
{!signeringslank ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleLock}
|
||||
disabled={!hasData || hasErrors || actionLoading !== null}
|
||||
className="gap-1.5"
|
||||
title={hasErrors ? 'Valideringsfel måste åtgärdas först' : ''}
|
||||
>
|
||||
{actionLoading === 'lock' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Lås och signera
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleUnlock}
|
||||
disabled={actionLoading !== null}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{actionLoading === 'unlock' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Unlock className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Lås upp
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCheckSubmitted}
|
||||
disabled={actionLoading !== null}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{actionLoading === 'check' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Kontrollera inlämning
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Disconnect */}
|
||||
<div className="pt-2 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
disabled={actionLoading !== null}
|
||||
className="gap-1.5 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{actionLoading === 'disconnect' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Link2Off className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Koppla bort Skatteverket
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<typeof rutorToMomsuppgift>
|
||||
}> {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<string> {
|
||||
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<Response> {
|
||||
const accessToken = await getValidToken(supabase, userId)
|
||||
|
||||
await enforceRateLimit()
|
||||
|
||||
const url = `${getApiBaseUrl()}${path}`
|
||||
const headers: Record<string, string> = {
|
||||
'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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<SkatteverketTokens> {
|
||||
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<SkatteverketTokens> {
|
||||
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 ?? '',
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<SkatteverketTokens | null> {
|
||||
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<void> {
|
||||
await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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<CounterpartyTemplateMatch | null> {
|
||||
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<void> {
|
||||
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'
|
||||
}
|
||||
@@ -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<MappingResult | null> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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> = {}
|
||||
): 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,
|
||||
}
|
||||
}
|
||||
|
||||
+55
-7
@@ -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<ReceiptStatus, string> = {
|
||||
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<keyof VatDeclarationRutor, string> = {
|
||||
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<keyof VatDeclarationRutor, string> = {
|
||||
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',
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user