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:
Jakob Wennberg
2026-03-24 13:41:12 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3050743013
commit d8e0a22495
33 changed files with 3097 additions and 158 deletions
+1 -1
View File
@@ -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.
+6 -24
View File
@@ -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 -37
View File
@@ -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>
+29 -59
View File
@@ -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>
+9
View File
@@ -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">
+13 -16
View File
@@ -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 {
+3 -5
View File
@@ -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>
)}
+527
View File
@@ -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>
)
}
+2 -1
View File
@@ -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."
+10
View File
@@ -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')
})
})
+648
View File
@@ -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."
}
}
+99
View File
@@ -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()
+355
View File
@@ -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'
}
+44 -2
View File
@@ -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
*/
+2 -2
View File
@@ -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,
+11 -4
View File
@@ -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
+60
View File
@@ -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')}`
}
+11
View File
@@ -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();
+25
View File
@@ -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
View File
@@ -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',
}
// ============================================================