Bug/customer invoice bug (#628)

* fix(supplier-invoices): self-assess reverse-charge VAT + link payments to vouchers

Reverse-charge supplier invoices now carry a per-item reverse_charge_rate (0.06/0.12/0.25). Under omvänd skattskyldighet the supplier charges 0% VAT, so the line vat_rate stays 0 and the buyer self-assesses fiktiv moms at the statutory rate. Centralizes rate resolution (resolveReverseChargeRate) and the ruta 20-24 basis-account guard (isReverseChargeBasisAccount) in vat-entries so the booking engine and review-dialog preview can no longer drift.

Adds the link_supplier_invoice_voucher pending operation: mark a leverantorsfaktura paid by linking an existing posted verifikat that debits 2440, with no new journal entry. Exposes find-candidates/link MCP tools and the bulk-reconcile helper, scoped under suppliers:read/write.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vat): report yearly VAT over the rakenskapsar, not the calendar year

Annual VAT (helarsmoms) is filed per beskattningsar/rakenskapsar (SFL 26 kap), which can be extended or shortened up to 18 months. The previous Jan-Dec calendar span silently dropped part of an extended first year. calculateVatDeclaration now accepts a fiscalPeriodId and resolves the period's actual bounds for yearly; monthly/quarterly stay calendar. The reports UI passes the selected fiscal period, defaults the periodicity from the company's moms_period setting, and carries the period into the ruta drill-down. full-archive export threads the period id through too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(migration): resolve supplier invoice status from payment amounts

The provider's lifecycle status and its payment status are computed independently upstream and can contradict each other (e.g. a Fortnox invoice marked booked but fully paid). Both the arcim entity-mapper and the Fortnox mapper now let payment state win: fully paid -> paid, partial -> partially_paid, otherwise the mapped lifecycle status, with credit notes forced terminal. Balance is compared numerically (never strict === 0) so float drift or a residual ore resolves cleanly, and an absent Balance is treated as unpaid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(enable-banking): only ingest booked transactions to stop re-import drift

Pending entries are skipped during sync: a pending row is unstable across syncs (a later 'synka nu' returns it still pending or finally booked, often with a different effective date). Because both the dedup external_id and the content-dedup key are date-derived, that drift minted a new id and re-imported a transaction that already existed - observed in production as the same amount+description landing twice with different dates. Gating the import set on a stable booking_date removes the drift at the source and leaves booked rows' ids byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(gitignore): ignore local SIE test fixtures

tests/fixtures/sie/ may contain real or scrubbed company data and must never be committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(invoice): handle errors during registration journal entry creation and ensure invoice rollback
feat(tests): add test for reverse charge rate handling on supplier invoice line items
feat(fortnox): ensure paid status reflects zero balance for fully paid invoices
chore(migrations): add reverse_charge_rate to supplier_invoice_items and backfill link_supplier_invoice_voucher

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-02 13:25:48 +02:00
committed by GitHub
parent 358c25094d
commit f6ee0c2a82
43 changed files with 2226 additions and 182 deletions
+4 -1
View File
@@ -67,4 +67,7 @@ supabase/.temp/
# out of the box without running the generator.
supabase/.branches/
/scripts
/scripts
# Local-only SIE test fixtures — may contain real/scrubbed company data, never commit
tests/fixtures/sie/
+120 -45
View File
@@ -1,6 +1,6 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
@@ -15,6 +15,7 @@ import { formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { AccountNumber } from '@/components/ui/account-number'
import { useCompany } from '@/contexts/CompanyContext'
import { useSettings } from '@/components/settings/useSettings'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange'
import { ReportsNav } from '@/components/reports/ReportsNav'
@@ -171,14 +172,6 @@ export default function ReportsPage() {
/>
)}
</div>
<p className="text-sm text-muted-foreground">
{t('sie_moved_hint')}{' '}
<Link href="/import?view=export#sie-export" className="text-foreground underline underline-offset-4 decoration-muted-foreground/40 hover:decoration-foreground">
{t('sie_moved_link')}
</Link>
.
</p>
{isLoadingInit ? (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
@@ -247,7 +240,12 @@ export default function ReportsPage() {
{activeTab === 'balance-sheet' && (
<BalanceSheetView periodId={selectedPeriod} dateRange={dateRange} onNavigateToAccount={navigateToAccount} />
)}
{activeTab === 'vat-declaration' && <VatDeclarationView />}
{activeTab === 'vat-declaration' && (
<VatDeclarationView
fiscalPeriodId={selectedPeriod}
fiscalPeriodBounds={selectedPeriodBounds}
/>
)}
{activeTab === 'periodisk-sammanstallning' && <PeriodiskSammanstallningView />}
{isEnskildFirma && activeTab === 'ne-declaration' && (
<NEDeclarationView periodId={selectedPeriod} />
@@ -1273,7 +1271,18 @@ function ReportSectionTable({
)
}
function VatDeclarationView() {
// Carries the selected fiscal period into the ruta drill-down rows so their
// source-verifikat query matches the report's period. Only set for yearly
// (räkenskapsår); undefined for monthly/quarterly (calendar periods).
const VatDrillContext = React.createContext<{ fiscalPeriodId?: string }>({})
function VatDeclarationView({
fiscalPeriodId,
fiscalPeriodBounds,
}: {
fiscalPeriodId: string
fiscalPeriodBounds: { start: string; end: string } | null
}) {
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1
const currentQuarter = Math.ceil(currentMonth / 3)
@@ -1285,6 +1294,25 @@ function VatDeclarationView() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Default the periodicity to the company's configured VAT reporting period
// (moms_period in Inställningar) so the picker mirrors the setting instead of
// always starting on quarterly. Applied once per company the first time its
// settings load; a later manual change to the picker is preserved, and a
// company switch re-applies the new company's setting. `useSettings` only
// refetches when the active company changes, so this never clobbers a manual
// selection mid-session.
const { settings } = useSettings()
const appliedForCompany = useRef<string | null>(null)
useEffect(() => {
const momsPeriod = settings?.moms_period
const companyId = settings?.company_id
if (!momsPeriod || !companyId) return
if (appliedForCompany.current === companyId) return
appliedForCompany.current = companyId
setPeriodType(momsPeriod)
// `period` is reset to a sensible value by the periodType effect below.
}, [settings])
// Generate year options (last 5 years)
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i)
@@ -1331,12 +1359,26 @@ function VatDeclarationView() {
}
}, [periodType, currentMonth, currentQuarter])
// Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar year.
// For yearly we pass the selected fiscal period so the API uses its actual
// bounds (handles extended/shortened years); monthly/quarterly stay calendar.
const isYearly = periodType === 'yearly'
const vatQueryString = () => {
const params = new URLSearchParams({
periodType,
year: String(year),
period: String(period),
})
if (isYearly && fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId)
return params.toString()
}
const fetchDeclaration = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(
`/api/reports/vat-declaration?periodType=${periodType}&year=${year}&period=${period}`
`/api/reports/vat-declaration?${vatQueryString()}`
)
const result = await res.json()
if (result.error) {
@@ -1352,12 +1394,13 @@ function VatDeclarationView() {
}
return (
<VatDrillContext.Provider value={{ fiscalPeriodId: isYearly ? fiscalPeriodId : undefined }}>
<div className="space-y-4">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => window.open(`/api/reports/vat-declaration/xlsx?periodType=${periodType}&year=${year}&period=${period}`, '_blank')}
onClick={() => window.open(`/api/reports/vat-declaration/xlsx?${vatQueryString()}`, '_blank')}
>
<FileSpreadsheet className="h-4 w-4 mr-2" />
Ladda ner Excel
@@ -1387,34 +1430,49 @@ function VatDeclarationView() {
<option value="yearly">Årsvis</option>
</select>
</div>
<div>
<Label>År</Label>
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{yearOptions.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
<div>
<Label>Period</Label>
<select
value={period}
onChange={(e) => setPeriod(parseInt(e.target.value))}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{getPeriodOptions().map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{isYearly ? (
// Annual VAT covers the selected räkenskapsår — driven by the
// fiscal-year picker at the top of the page, not a calendar year.
<div>
<Label>Räkenskapsår</Label>
<div className="mt-1 rounded-md border border-input bg-muted/40 px-3 py-2 text-sm tabular-nums">
{fiscalPeriodBounds
? `${formatDate(fiscalPeriodBounds.start)} ${formatDate(fiscalPeriodBounds.end)}`
: '—'}
</div>
</div>
) : (
<>
<div>
<Label>År</Label>
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{yearOptions.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
<div>
<Label>Period</Label>
<select
value={period}
onChange={(e) => setPeriod(parseInt(e.target.value))}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{getPeriodOptions().map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</>
)}
<Button onClick={fetchDeclaration} disabled={loading}>
{loading ? 'Laddar...' : 'Hämta'}
</Button>
@@ -1646,13 +1704,29 @@ function VatDeclarationView() {
</Card>
)}
</div>
</VatDrillContext.Provider>
)
}
function makeVatFetcher(ruta: string, periodType: VatPeriodType, year: number, period: number): ReportSourceFetcher {
function makeVatFetcher(
ruta: string,
periodType: VatPeriodType,
year: number,
period: number,
fiscalPeriodId?: string,
): ReportSourceFetcher {
return async () => {
const params = new URLSearchParams({
periodType,
year: String(year),
period: String(period),
})
// Yearly drill-down resolves against the räkenskapsår, matching the report.
if (periodType === 'yearly' && fiscalPeriodId) {
params.set('fiscal_period_id', fiscalPeriodId)
}
const res = await fetch(
`/api/reports/vat-declaration/ruta/${encodeURIComponent(ruta)}/sources?periodType=${periodType}&year=${year}&period=${period}`
`/api/reports/vat-declaration/ruta/${encodeURIComponent(ruta)}/sources?${params.toString()}`
)
const json = await res.json()
if (!res.ok) throw new Error(json.error || 'Kunde inte hämta verifikat')
@@ -1680,10 +1754,11 @@ function VatRutaRow({
year?: number
period?: number
}) {
const { fiscalPeriodId } = React.useContext(VatDrillContext)
const canDrill = periodType !== undefined && year !== undefined && period !== undefined
const fetcher = React.useMemo(
() => (canDrill ? makeVatFetcher(ruta, periodType!, year!, period!) : null),
[canDrill, ruta, periodType, year, period]
() => (canDrill ? makeVatFetcher(ruta, periodType!, year!, period!, fiscalPeriodId) : null),
[canDrill, ruta, periodType, year, period, fiscalPeriodId]
)
// Hooks must be called unconditionally — provide a noop fetcher when drill
// is disabled. The early-return for zero rows lives below the hooks.
+94 -48
View File
@@ -24,8 +24,7 @@ import { cn, formatCurrency } from '@/lib/utils'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import BankTransactionPicker from '@/components/transactions/BankTransactionPicker'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, CalendarPlus, MessageCircle, Link2 } from 'lucide-react'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2 } from 'lucide-react'
import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult, FiscalPeriod } from '@/types'
interface LineItem {
@@ -33,6 +32,9 @@ interface LineItem {
amount: number
account_number: string
vat_rate: number
// Self-assessed VAT rate for omvänd skattskyldighet (0.25/0.12/0.06). Only
// meaningful when reverse_charge is on; the line's vat_rate is then 0.
reverse_charge_rate?: number
}
interface FormData {
@@ -184,6 +186,26 @@ function VatRateCell({ value, onChange }: { value: number; onChange: (v: number)
)
}
// Self-assessment rate picker shown in place of the Momssats cell when an
// invoice is reverse charge. The supplier charges no VAT (the line vat_rate is
// 0); this is the Swedish statutory rate the buyer self-assesses at — 25%
// huvudregeln for EU services, 12%/6% for reduced-rated services (ML 6 kap 34 §).
function RcRateSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) {
const t = useTranslations('supplier_invoice_editor')
return (
<Select value={String(value ?? 0.25)} onValueChange={(v) => onChange(parseFloat(v))}>
<SelectTrigger className="h-9 tabular-nums" aria-label={t('col_rc_vat_rate')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.25" className="tabular-nums">25 %</SelectItem>
<SelectItem value="0.12" className="tabular-nums">12 %</SelectItem>
<SelectItem value="0.06" className="tabular-nums">6 %</SelectItem>
</SelectContent>
</Select>
)
}
const EMPTY_NEW_SUPPLIER: NewSupplierForm = {
name: '',
supplier_type: 'swedish_business',
@@ -221,7 +243,6 @@ export default function NewSupplierInvoicePage() {
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [periodsLoaded, setPeriodsLoaded] = useState(false)
const [showCreatePeriod, setShowCreatePeriod] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [showReview, setShowReview] = useState(false)
const [pendingData, setPendingData] = useState<FormData | null>(null)
@@ -268,7 +289,7 @@ export default function NewSupplierInvoicePage() {
payment_reference: '',
notes: '',
paid_with_private_funds: false,
items: [{ description: '', amount: 0, account_number: '5010', vat_rate: 0.25 }],
items: [{ description: '', amount: 0, account_number: '5010', vat_rate: 0.25, reverse_charge_rate: 0.25 }],
},
})
@@ -559,7 +580,10 @@ export default function NewSupplierInvoicePage() {
const itemTotals = (watchedItems || []).map((item) => {
const lineTotal = Math.round((item.amount || 0) * 100) / 100
const vatAmount = Math.round(lineTotal * (item.vat_rate || 0) * 100) / 100
// Reverse charge: VAT is self-assessed at reverse_charge_rate (25% default),
// not the line's vat_rate (which is 0 — the supplier charged nothing).
const effectiveRate = watchedReverseCharge ? (item.reverse_charge_rate ?? 0.25) : (item.vat_rate || 0)
const vatAmount = Math.round(lineTotal * effectiveRate * 100) / 100
return { lineTotal, vatAmount }
})
const subtotal = itemTotals.reduce((sum, t) => sum + t.lineTotal, 0)
@@ -660,7 +684,10 @@ export default function NewSupplierInvoicePage() {
description: item.description,
amount: item.amount,
account_number: item.account_number,
vat_rate: item.vat_rate,
// Reverse charge: the supplier charges no VAT, so the line rate is 0 and
// the self-assessed rate travels on reverse_charge_rate (25% default).
vat_rate: data.reverse_charge ? 0 : item.vat_rate,
reverse_charge_rate: data.reverse_charge ? (item.reverse_charge_rate ?? 0.25) : undefined,
})),
}
}
@@ -731,6 +758,20 @@ export default function NewSupplierInvoicePage() {
}
function onSubmit(data: FormData) {
// Hard block: under faktureringsmetoden (and for privately-paid kvitton) a
// verifikation is posted at registration, and BFL 5 kap kräver att
// verifikationsnumret ligger i en obruten serie inom ett räkenskapsår. No
// räkenskapsår for the invoice date → no compliant voucher can exist, so we
// refuse rather than register an unbooked invoice. Kontantmetoden books at
// payment, so it is intentionally not blocked here (see showNoPeriodWarning).
if (showNoPeriodWarning) {
toast({
title: t('warning_title'),
description: t('no_period_warning', { date: data.invoice_date }),
variant: 'destructive',
})
return
}
if (!data.supplier_id) {
toast({ title: t('supplier_missing_title'), description: t('supplier_missing_description'), variant: 'destructive' })
return
@@ -1166,22 +1207,15 @@ export default function NewSupplierInvoicePage() {
</div>
{showNoPeriodWarning && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
<div className="flex-1 text-sm text-warning-foreground">
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/10 p-3"
>
<AlertCircle className="h-5 w-5 text-destructive mt-0.5 shrink-0" />
<div className="flex-1 text-sm text-destructive">
<p className="font-medium">{t('no_period_warning', { date: watchedInvoiceDate })}</p>
<p className="mt-0.5">{t('no_period_help')}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowCreatePeriod(true)}
className="shrink-0"
>
<CalendarPlus className="h-3.5 w-3.5 mr-1.5" />
{t('create_period')}
</Button>
</div>
)}
</CardContent>
@@ -1197,7 +1231,7 @@ export default function NewSupplierInvoicePage() {
size="sm"
className="w-full sm:w-auto"
onClick={() =>
append({ description: '', amount: 0, account_number: '', vat_rate: 0.25 })
append({ description: '', amount: 0, account_number: '', vat_rate: 0.25, reverse_charge_rate: 0.25 })
}
>
<Plus className="mr-2 h-4 w-4" />
@@ -1282,8 +1316,8 @@ export default function NewSupplierInvoicePage() {
<th className="pb-2 w-28">{t('col_account')}</th>
<th className="pb-2">{t('col_description')}</th>
<th className="pb-2 w-32">{t('col_amount_excl')}</th>
<th className="pb-2 w-36">{t('col_vat_rate')}</th>
<th className="pb-2 w-24 text-right">{t('col_vat')}</th>
<th className="pb-2 w-36">{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}</th>
<th className="pb-2 w-24 text-right">{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}</th>
<th className="pb-2 w-8"></th>
</tr>
</thead>
@@ -1336,13 +1370,23 @@ export default function NewSupplierInvoicePage() {
/>
</td>
<td className="py-2 pr-2">
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
{watchedReverseCharge ? (
<Controller
name={`items.${index}.reverse_charge_rate`}
control={control}
render={({ field: f }) => (
<RcRateSelect value={f.value ?? 0.25} onChange={f.onChange} />
)}
/>
) : (
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
)}
</td>
<td className="py-2 pr-2 text-right tabular-nums text-muted-foreground">
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
@@ -1418,18 +1462,28 @@ export default function NewSupplierInvoicePage() {
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('col_vat_rate')}</Label>
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
<Label className="text-xs text-muted-foreground">{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}</Label>
{watchedReverseCharge ? (
<Controller
name={`items.${index}.reverse_charge_rate`}
control={control}
render={({ field: f }) => (
<RcRateSelect value={f.value ?? 0.25} onChange={f.onChange} />
)}
/>
) : (
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
)}
</div>
</div>
<div className="pt-1 border-t flex items-center justify-between">
<span className="text-xs text-muted-foreground">{t('col_vat')}</span>
<span className="text-xs text-muted-foreground">{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}</span>
<span className="tabular-nums text-muted-foreground">
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
</span>
@@ -1515,7 +1569,7 @@ export default function NewSupplierInvoicePage() {
type="submit"
variant="outline"
className="w-full sm:w-auto"
disabled={isSubmitting || !canWrite}
disabled={isSubmitting || !canWrite || showNoPeriodWarning}
onClick={() => { submitModeRef.current = 'register_and_match' }}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
@@ -1525,7 +1579,7 @@ export default function NewSupplierInvoicePage() {
)}
<Button
type="submit"
disabled={isSubmitting || !canWrite}
disabled={isSubmitting || !canWrite || showNoPeriodWarning}
className="w-full sm:w-auto"
onClick={() => { submitModeRef.current = 'register' }}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
@@ -1733,14 +1787,6 @@ export default function NewSupplierInvoicePage() {
</div>
</DialogContent>
</Dialog>
<CreatePeriodDialog
open={showCreatePeriod}
onOpenChange={setShowCreatePeriod}
entryDate={watchedInvoiceDate}
periods={periods}
onCreated={fetchPeriods}
/>
</div>
)
}
+7
View File
@@ -24,6 +24,10 @@ export const GET = withRouteContext(
const periodType = searchParams.get('periodType') as VatPeriodType | null
const yearStr = searchParams.get('year')
const periodStr = searchParams.get('period')
// For yearly (helårsmoms) the period is the räkenskapsår, not the calendar
// year; the client passes the selected fiscal period so an extended year is
// covered in full. Ignored for monthly/quarterly (calendar periods).
const fiscalPeriodId = searchParams.get('fiscal_period_id') ?? undefined
if (!periodType || !yearStr || !periodStr) {
return errorResponseFromCode('VAT_REPORT_MISSING_PARAMS', log, { requestId })
@@ -83,11 +87,14 @@ export const GET = withRouteContext(
try {
const declaration = await calculateVatDeclaration(
supabase, companyId!, periodType, year, period, accountingMethod,
{ fiscalPeriodId },
)
return NextResponse.json({
data: {
...declaration,
// For yearly the authoritative span is declaration.period.start/end
// (the räkenskapsår). The label stays a coarse "Helår {year}".
periodLabel: formatPeriodLabel(periodType, year, period),
},
})
@@ -38,6 +38,8 @@ export async function GET(request: Request) {
const periodType = searchParams.get('periodType') as VatPeriodType | null
const yearStr = searchParams.get('year')
const periodStr = searchParams.get('period')
// Yearly = räkenskapsår (see main route); ignored for monthly/quarterly.
const fiscalPeriodId = searchParams.get('fiscal_period_id') ?? undefined
if (!periodType || !yearStr || !periodStr) {
return NextResponse.json(
@@ -73,6 +75,7 @@ export async function GET(request: Request) {
try {
const declaration = await calculateVatDeclaration(
supabase, companyId, periodType, year, period, accountingMethod,
{ fiscalPeriodId },
)
const rows: RutaRow[] = (Object.keys(declaration.rutor) as (keyof VatDeclarationRutor)[]).map(
@@ -82,6 +82,9 @@ export const POST = withRouteContext(
vat_code: item.vat_code,
vat_rate: item.vat_rate,
vat_amount: item.vat_amount,
// Preserve the self-assessed RC rate so the credit-note verifikat
// reverses fiktiv moms at the same rate the original was booked at.
reverse_charge_rate: item.reverse_charge_rate,
}))
await supabase.from('supplier_invoice_items').insert(creditItems)
@@ -208,6 +208,7 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
vat_code: null,
vat_rate: 0.25,
vat_amount: 2000,
reverse_charge_rate: null,
created_at: '2024-06-01T00:00:00Z',
},
],
+4
View File
@@ -132,6 +132,10 @@ export const POST = withRouteContext(
vat_code: item.vat_code || null,
vat_rate: vatRate,
vat_amount: vatAmount,
// Self-assessed RC rate (0.06/0.12/0.25) or null. For reverse charge the
// supplier charges no VAT (vat_rate stays 0); the engine self-assesses
// at this rate, defaulting to 25% huvudregeln when null.
reverse_charge_rate: body.reverse_charge ? (item.reverse_charge_rate ?? null) : null,
}
})
@@ -57,7 +57,7 @@ const SI_FULL_COLUMNS = `
vat_treatment, reverse_charge, remaining_amount,
is_credit_note, credited_invoice_id, arrival_number,
supplier:suppliers(id, name, supplier_type),
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount)
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate)
`
const SupplierInvoiceCredited = z.object({
@@ -166,6 +166,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
vat_code: string | null
vat_rate: number
vat_amount: number
reverse_charge_rate: number | null
}>
} & Record<string, unknown>
@@ -215,6 +216,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
vat_code: item.vat_code,
vat_rate: item.vat_rate,
vat_amount: item.vat_amount,
reverse_charge_rate: item.reverse_charge_rate,
}))
return dryRunPreview(
{
@@ -306,6 +308,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
vat_code: item.vat_code,
vat_rate: item.vat_rate,
vat_amount: item.vat_amount,
// Preserve the self-assessed RC rate so the credit note reverses fiktiv
// moms at the same rate the original was booked at.
reverse_charge_rate: item.reverse_charge_rate,
}))
if (creditItems.length > 0) {
const { error: itemsErr } = await ctx.supabase
@@ -169,7 +169,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
subtotal, subtotal_sek, vat_amount, vat_amount_sek, total_sek, due_date, received_date,
is_credit_note, credited_invoice_id, payment_journal_entry_id,
supplier:suppliers(id, name, supplier_type),
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount)
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate)
`)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
@@ -34,7 +34,7 @@ const SI_DETAIL_COLUMNS =
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_at, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, transaction_id, document_id, notes, reversed_at, created_at, updated_at'
const SI_ITEM_COLUMNS =
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount'
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate'
const SI_PAYMENT_COLUMNS =
'id, payment_date, amount, currency, exchange_rate, exchange_rate_difference, journal_entry_id, transaction_id, notes, created_at'
@@ -264,7 +264,7 @@ const SI_RESPONSE_COLUMNS =
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, notes, created_at, updated_at'
const SI_ITEMS_RESPONSE_COLUMNS =
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount'
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate'
const SupplierInvoiceCreated = z.object({
id: z.string().uuid(),
@@ -345,6 +345,7 @@ interface ComputedItem {
vat_code: string | null
vat_rate: number
vat_amount: number
reverse_charge_rate: number | null
}
// Swedish VAT rates per ML 2 kap 1 § + Skatteverket's 2026 satser. Allow
@@ -385,6 +386,10 @@ function computeItemsAndTotals(input: z.infer<typeof CreateSupplierInvoiceSchema
vat_code: item.vat_code || null,
vat_rate: vatRate,
vat_amount: vatAmount,
// Self-assessed RC rate (0.06/0.12/0.25) or null. For reverse charge the
// line vat_rate is 0 (validated below); the engine self-assesses at this
// rate, defaulting to 25% huvudregeln when null.
reverse_charge_rate: item.reverse_charge_rate ?? null,
})
}
const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
@@ -5,6 +5,11 @@ import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { AccountNumber } from '@/components/ui/account-number'
import { formatCurrency } from '@/lib/utils'
import {
resolveReverseChargeRate,
isReverseChargeBasisAccount,
generateReverseChargeBasisLines,
} from '@/lib/bookkeeping/vat-entries'
import type { Supplier } from '@/types'
interface ReviewLineItem {
@@ -15,6 +20,9 @@ interface ReviewLineItem {
// When set, the user typed the deductible VAT explicitly (manual override).
// Used for bilförmån 50%, representation tak, FX-rundningar etc.
vat_amount?: number
// Self-assessed VAT rate for omvänd skattskyldighet (0.06/0.12/0.25). The
// supplier charges no VAT (vat_rate = 0); this drives the fiktiv-moms preview.
reverse_charge_rate?: number
}
interface SupplierInvoiceReviewContentProps {
@@ -91,20 +99,38 @@ function buildJournalPreview(
: Math.round(item.amount * item.vat_rate * 100) / 100
if (reverseCharge) {
// Reverse charge: fiktiv moms is always statutory base × rate, regardless
// of any manual override on the items themselves (matches engine).
// Reverse charge: the supplier charges no VAT, so the buyer self-assesses at
// the Swedish statutory rate (resolveReverseChargeRate — 25% huvudregel
// default, or the per-item reverse_charge_rate). We book BOTH the fiktiv-moms
// pair (2645/2647 + 2614/2624/2634) AND the basbeloppsrader (44xx/45xx +
// 4598), exactly as the engine does, so this preview matches the saved
// verifikat. ML 16 kap requires both sides reported; silent netting is
// prohibited (Skatteverket felkod FK004). Driving off the resolved rate (not
// item.vat_rate) is what makes a 0%-rate RC line book its VAT at all.
const isDomesticRC = supplierType === 'swedish_business'
const inputAccount = isDomesticRC ? '2647' : '2645'
const rcSupplierType: 'eu_business' | 'non_eu_business' | 'swedish_business' =
supplierType === 'non_eu_business' || supplierType === 'swedish_business'
? supplierType
: 'eu_business'
// Base per self-assessed rate, plus the non-basis-account portion that needs
// parallel basbeloppsrader (items booked straight to a 44xx/45xx basis
// account already populate ruta 20-24 via the expense line, so they're
// excluded there to avoid double-counting).
const baseByRate = new Map<number, number>()
const nonBasisBaseByRate = new Map<number, number>()
for (const item of items) {
if (item.vat_rate > 0) {
const current = baseByRate.get(item.vat_rate) || 0
baseByRate.set(item.vat_rate, current + toSek(item.amount))
const rate = resolveReverseChargeRate(item)
const sek = toSek(item.amount)
baseByRate.set(rate, (baseByRate.get(rate) || 0) + sek)
if (!isReverseChargeBasisAccount(item.account_number)) {
nonBasisBaseByRate.set(rate, (nonBasisBaseByRate.get(rate) || 0) + sek)
}
}
for (const [rate, netAmount] of baseByRate) {
if (netAmount <= 0) continue
const fiktivVat = Math.round(netAmount * rate * 100) / 100
const outputAccount = getOutputVatAccount(rate)
lines.push({
@@ -119,6 +145,17 @@ function buildJournalPreview(
debit: 0,
credit: fiktivVat,
})
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
for (const bl of generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)) {
lines.push({
account_number: bl.account_number,
description: bl.line_description ?? bl.account_number,
debit: bl.debit_amount,
credit: bl.credit_amount,
})
}
}
}
// Credit: 2440 at subtotal (no real VAT for reverse charge)
@@ -248,9 +285,16 @@ export function SupplierInvoiceReviewContent({
</thead>
<tbody>
{items.map((item, index) => {
const vatAmount = item.vat_amount != null
? Math.round(item.vat_amount * 100) / 100
: Math.round(item.amount * item.vat_rate * 100) / 100
// For reverse charge the supplier charges 0%, so show the
// self-assessed rate/amount the buyer books (matches the voucher
// preview below). Manual vat_amount overrides only apply to
// ordinary deductible VAT, never to RC self-assessment.
const displayRate = reverseCharge ? resolveReverseChargeRate(item) : item.vat_rate
const vatAmount = reverseCharge
? Math.round(item.amount * displayRate * 100) / 100
: item.vat_amount != null
? Math.round(item.vat_amount * 100) / 100
: Math.round(item.amount * item.vat_rate * 100) / 100
return (
<tr key={index} className="border-b last:border-0">
<td className="py-2">
@@ -258,7 +302,7 @@ export function SupplierInvoiceReviewContent({
</td>
<td className="py-2">{item.description}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.amount)}</td>
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right">{Math.round(displayRate * 100)}%</td>
<td className="py-2 text-right font-mono">{formatAmount(vatAmount)}</td>
</tr>
)
@@ -268,9 +312,12 @@ export function SupplierInvoiceReviewContent({
</div>
<div className="sm:hidden space-y-2">
{items.map((item, index) => {
const vatAmount = item.vat_amount != null
? Math.round(item.vat_amount * 100) / 100
: Math.round(item.amount * item.vat_rate * 100) / 100
const displayRate = reverseCharge ? resolveReverseChargeRate(item) : item.vat_rate
const vatAmount = reverseCharge
? Math.round(item.amount * displayRate * 100) / 100
: item.vat_amount != null
? Math.round(item.vat_amount * 100) / 100
: Math.round(item.amount * item.vat_rate * 100) / 100
return (
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
<div className="flex items-center justify-between">
@@ -279,7 +326,7 @@ export function SupplierInvoiceReviewContent({
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>{formatAmount(item.amount)} kr</span>
<span className="text-xs">{t('review_vat_inline', { rate: Math.round(item.vat_rate * 100), amount: formatAmount(vatAmount) })}</span>
<span className="text-xs">{t('review_vat_inline', { rate: Math.round(displayRate * 100), amount: formatAmount(vatAmount) })}</span>
</div>
</div>
)
@@ -293,7 +340,7 @@ export function SupplierInvoiceReviewContent({
<span>{formatCurrency(subtotal, currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('vat_label_short')}</span>
<span className="text-muted-foreground">{reverseCharge ? t('vat_reverse_charge') : t('vat_label_short')}</span>
<span>{formatCurrency(totalVat, currency)}</span>
</div>
<Separator />
@@ -15,6 +15,7 @@ import {
} from './lib/provider-client'
import { mapCompanyInfo } from './lib/entity-mapper'
import { executeMigration } from './lib/migration-orchestrator'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
import type { ArcimProvider } from './types'
import { ARCIM_PROVIDERS } from './types'
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
@@ -1001,6 +1002,7 @@ export const arcimMigrationExtension: Extension = {
importSuppliers = true,
importSalesInvoices = true,
importSupplierInvoices = true,
reconcileVouchers = true,
} = await request.json() as {
consentId: string
importCompanyInfo?: boolean
@@ -1008,6 +1010,7 @@ export const arcimMigrationExtension: Extension = {
importSuppliers?: boolean
importSalesInvoices?: boolean
importSupplierInvoices?: boolean
reconcileVouchers?: boolean
}
if (!consentId) {
@@ -1034,6 +1037,7 @@ export const arcimMigrationExtension: Extension = {
importSuppliers,
importSalesInvoices,
importSupplierInvoices,
reconcileVouchers,
})
log.info('Migration completed:', results)
@@ -1055,6 +1059,60 @@ export const arcimMigrationExtension: Extension = {
},
},
// ── Reconcile supplier invoices to GL payment vouchers ────────
// Re-runnable maintenance endpoint. The migration runs this automatically as
// its final step, but SIE (the GL) and entity import are two separate HTTP
// requests whose order is UI-driven — so if the GL lands after the entity
// import, or a company was migrated before this feature existed, call this to
// auto-link settled supplier invoices to their existing vouchers. Pass
// { dryRun: true } to preview the plan (incl. items needing manual review)
// without writing.
{
method: 'POST',
path: '/reconcile',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = ctx?.companyId ?? user.id
let dryRun = false
try {
const body = (await request.json()) as { dryRun?: boolean }
dryRun = body?.dryRun === true
} catch {
// empty body is fine — default to a real run
}
try {
const result = await reconcileSupplierInvoiceVouchers({
supabase,
companyId,
userId: user.id,
dryRun,
})
log.info('arcim reconcile completed', {
companyId,
dryRun,
autoLinked: result.autoLinked,
ambiguous: result.ambiguous,
unmatched: result.unmatched,
})
return NextResponse.json({ success: true, dryRun, result })
} catch (error) {
log.error('arcim reconcile failed', error as Error)
return errorResponseFromCode('PROVIDER_MIGRATE_FAILED', moduleLog, {
details: { reason: error instanceof Error ? error.message : 'unknown' },
})
}
},
},
// ── Accept consent (mark as fully connected after import) ─────
{
method: 'POST',
@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest'
import { mapSupplierInvoice } from '../entity-mapper'
import type { SupplierInvoiceDto, InvoiceStatusCode, PartyDto } from '@/lib/providers/dto'
/**
* Guards the status/paid consistency hardening in mapSupplierInvoice: the
* provider's lifecycle status (dto.status) and its payment status are computed
* independently upstream and can contradict each other. The mapper must emit a
* `status` that always agrees with paid_amount / remaining_amount, and treat
* Balance numerically (drift-safe), without ever flipping a credit note.
*/
const party: PartyDto = { name: 'Leverantör AB', identifications: [] }
function makeDto(over: {
status?: InvoiceStatusCode
paid?: boolean
balance?: number
total?: number
invoiceTypeCode?: string
lastPaymentDate?: string
}): SupplierInvoiceDto {
const total = over.total ?? 1000
return {
id: 'inv-1',
invoiceNumber: 'F-100',
issueDate: '2026-01-10',
dueDate: '2026-02-10',
invoiceTypeCode: over.invoiceTypeCode,
currencyCode: 'SEK',
status: over.status ?? 'booked',
supplier: party,
buyer: party,
lines: [
{
id: '1',
description: 'Tjänst',
lineExtensionAmount: { value: total, currencyCode: 'SEK' },
taxPercent: 25,
},
],
legalMonetaryTotal: {
lineExtensionAmount: { value: total, currencyCode: 'SEK' },
payableAmount: { value: total, currencyCode: 'SEK' },
},
paymentStatus: {
paid: over.paid ?? false,
balance: { value: over.balance ?? total, currencyCode: 'SEK' },
lastPaymentDate: over.lastPaymentDate,
},
}
}
function map(over: Parameters<typeof makeDto>[0]) {
return mapSupplierInvoice(makeDto(over), 'user-1', 'company-1', 'supplier-1').invoice
}
describe('mapSupplierInvoice — status/paid consistency', () => {
it('unpaid booked invoice → registered with full remaining', () => {
const inv = map({ status: 'booked', paid: false, balance: 1000, total: 1000 })
expect(inv.status).toBe('registered')
expect(inv.paid_amount).toBe(0)
expect(inv.remaining_amount).toBe(1000)
expect(inv.paid_at).toBeNull()
})
it('booked-but-paid invoice → flips to paid (status follows payment)', () => {
// The bug: dto.status='booked' (→registered) while paymentStatus.paid=true.
const inv = map({ status: 'booked', paid: true, balance: 0, total: 1000, lastPaymentDate: '2026-02-05' })
expect(inv.status).toBe('paid')
expect(inv.paid_amount).toBe(1000)
expect(inv.remaining_amount).toBe(0)
expect(inv.paid_at).toBe('2026-02-05')
})
it('near-zero residual balance (0.004) resolves to paid, not unpaid', () => {
const inv = map({ status: 'booked', paid: false, balance: 0.004, total: 1000 })
expect(inv.status).toBe('paid')
expect(inv.remaining_amount).toBe(0)
expect(inv.paid_amount).toBe(1000)
})
it('partially-paid invoice (0 < paid < total) → partially_paid', () => {
const inv = map({ status: 'booked', paid: false, balance: 300, total: 1000 })
expect(inv.status).toBe('partially_paid')
expect(inv.paid_amount).toBe(700)
expect(inv.remaining_amount).toBe(300)
expect(inv.paid_at).not.toBeNull()
})
it('credit note with zero balance stays credited — never flipped to paid', () => {
const inv = map({ status: 'credited', paid: true, balance: 0, total: 1000, invoiceTypeCode: '381' })
expect(inv.status).toBe('credited')
expect(inv.is_credit_note).toBe(true)
})
it('credit note is forced to credited even if the provider sends a non-terminal status', () => {
// invoiceTypeCode='381' but a contradictory lifecycle status (the arcim
// gateway does not guarantee status='credited' alongside the type code).
for (const status of ['booked', 'paid', 'sent', 'draft'] as InvoiceStatusCode[]) {
const inv = map({ status, paid: true, balance: 0, total: 1000, invoiceTypeCode: '381' })
expect(inv.status, `status=${status}`).toBe('credited')
expect(inv.is_credit_note).toBe(true)
expect(inv.paid_at).toBeNull()
}
})
it('overdue lifecycle status is preserved when nothing is paid', () => {
const inv = map({ status: 'overdue', paid: false, balance: 1000, total: 1000 })
expect(inv.status).toBe('overdue')
expect(inv.remaining_amount).toBe(1000)
})
it('never emits a status outside the supplier_invoices CHECK allow-list', () => {
const allowed = new Set([
'registered', 'approved', 'paid', 'partially_paid', 'overdue', 'disputed', 'credited', 'reversed',
])
for (const status of ['draft', 'sent', 'booked', 'paid', 'overdue', 'cancelled', 'credited'] as InvoiceStatusCode[]) {
for (const paid of [true, false]) {
for (const balance of [0, 250, 1000]) {
const inv = map({ status, paid, balance, total: 1000 })
expect(allowed.has(inv.status as string)).toBe(true)
}
}
}
})
})
@@ -357,6 +357,38 @@ export function mapSupplierInvoice(
const isCreditNote = dto.invoiceTypeCode === '381'
// Payment-derived amounts. Treat Balance numerically (never strict === 0) so
// floating drift or a residual öre resolves cleanly to paid/unpaid.
const balance = round2(dto.paymentStatus.balance.value)
const paidAmount = dto.paymentStatus.paid ? total : round2(total - balance)
// Status MUST stay consistent with the payment amounts. The provider's
// lifecycle status (dto.status) and its payment status are computed
// independently upstream and can contradict each other (e.g. a Fortnox
// invoice that is "booked" but fully paid). Payment state wins:
// fully paid -> 'paid'
// 0 < paid < total -> 'partially_paid'
// otherwise -> the mapped lifecycle status
const mappedStatus = statusMap[dto.status] || 'registered'
let resolvedStatus: string
if (isCreditNote) {
// A kreditfaktura is never an open or "paid" payable. Force a credit-note
// terminal status regardless of the provider's lifecycle status — the
// arcim gateway is the only source of invoiceTypeCode and is NOT guaranteed
// to also send status='credited', so trusting dto.status here could persist
// a credit note as 'registered'/'paid' (contradicting its amounts).
resolvedStatus = mappedStatus === 'reversed' ? 'reversed' : 'credited'
} else if (mappedStatus === 'credited' || mappedStatus === 'reversed') {
// Terminal states from the provider: never flipped by payment.
resolvedStatus = mappedStatus
} else if (dto.paymentStatus.paid || balance <= 0) {
resolvedStatus = 'paid'
} else if (paidAmount > 0 && paidAmount < total) {
resolvedStatus = 'partially_paid'
} else {
resolvedStatus = mappedStatus
}
const invoice: Record<string, unknown> = {
user_id: userId,
company_id: companyId,
@@ -366,7 +398,7 @@ export function mapSupplierInvoice(
due_date: dto.dueDate || dto.issueDate,
received_date: dto.issueDate,
delivery_date: dto.deliveryDate || null,
status: statusMap[dto.status] || 'registered',
status: resolvedStatus,
currency: dto.currencyCode || 'SEK',
exchange_rate: dto.currencyCode === 'SEK' ? null : null,
subtotal,
@@ -378,9 +410,11 @@ export function mapSupplierInvoice(
vat_treatment: vatTreatment,
reverse_charge: vatTreatment === 'reverse_charge',
payment_reference: dto.ocrNumber || null,
paid_at: dto.paymentStatus.paid ? dto.paymentStatus.lastPaymentDate || dto.issueDate : null,
paid_amount: dto.paymentStatus.paid ? total : round2(total - dto.paymentStatus.balance.value),
remaining_amount: round2(dto.paymentStatus.balance.value),
paid_at: resolvedStatus === 'paid' || resolvedStatus === 'partially_paid'
? dto.paymentStatus.lastPaymentDate || dto.issueDate
: null,
paid_amount: resolvedStatus === 'paid' ? total : Math.max(0, paidAmount),
remaining_amount: resolvedStatus === 'paid' ? 0 : Math.max(0, balance),
is_credit_note: isCreditNote,
notes: dto.note || null,
}
@@ -30,6 +30,7 @@ import {
fetchSupplierInvoicesDirect,
} from '@/lib/providers/provider-data-fetcher'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
import {
mapCustomer,
mapSupplier,
@@ -49,6 +50,8 @@ export interface MigrationOptions {
importSuppliers?: boolean
importSalesInvoices?: boolean
importSupplierInvoices?: boolean
/** Auto-link imported supplier invoices to GL payment vouchers. Default true. */
reconcileVouchers?: boolean
onProgress?: (progress: MigrationProgress) => void
}
@@ -666,6 +669,30 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
// ── Step 6: Reconcile supplier invoices to GL payment vouchers ────
// The GL (incl. the Dr 2440 / Cr 1930 bank-payment vouchers) is imported
// separately via SIE. Supplier invoices arrive (via ?filter=unpaid) as open
// payables with no link to those vouchers, so settled invoices would surface
// as overdue. Auto-link the unambiguous matches. Best-effort: a failure here
// must never fail the migration — the imported data is already persisted.
if (options.reconcileVouchers !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Stämmer av betalningar mot verifikationer...', progress: 95 })
try {
const recon = await reconcileSupplierInvoiceVouchers({ supabase, companyId, userId })
results.reconciliation = {
scanned: recon.scanned,
autoLinked: recon.autoLinked,
ambiguous: recon.ambiguous,
unmatched: recon.unmatched,
}
console.log(
`[migration] Reconcile: ${recon.autoLinked} auto-linked, ${recon.ambiguous} need review, ${recon.unmatched} unmatched (${recon.scanned} scanned)`,
)
} catch (err) {
console.error('Failed to reconcile supplier invoice payments:', err)
}
}
emitProgress(options, { status: 'completed', progress: 100, results })
return results
} catch (error) {
@@ -65,6 +65,13 @@ export interface MigrationResults {
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
/**
* Auto-reconciliation of imported supplier invoices to the GL payment
* vouchers that the separate SIE import already posted. `autoLinked` invoices
* are now marked paid; `ambiguous` need manual review; `unmatched` had no
* candidate voucher.
*/
reconciliation?: { scanned: number; autoLinked: number; ambiguous: number; unmatched: number }
}
// ── Consent flow ────────────────────────────────────────────────────
@@ -184,7 +184,7 @@ describe('syncAccountTransactions', () => {
it('passes raw transactions to ingest function', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [{ transaction_amount: { amount: '500', currency: 'SEK' } }],
transactions: [{ transaction_amount: { amount: '500', currency: 'SEK' }, booking_date: '2024-06-15' }],
rawPages: ['{}'],
})
@@ -222,6 +222,83 @@ describe('syncAccountTransactions', () => {
expect(rawTxns[0].import_source).toBe('enable_banking')
})
it('skips pending entries (no booking_date) and ingests only booked ones', async () => {
// A pending PSD2 entry has no booking_date — only a value_date. Importing it
// is what produced the production duplicates: its date (hence its dedup id)
// differs from the same transaction's booked representation. Pending entries
// must be skipped; booked ones import as usual, keyed on booking_date.
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date?: string, value_date?: string }) => ({
id: 'x',
date: tx.booking_date || tx.value_date,
booking_date: tx.booking_date || tx.value_date,
amount: -parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'T',
}))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [
{ transaction_amount: { amount: '50', currency: 'SEK' }, value_date: '2026-02-15' }, // pending → skipped
{ transaction_amount: { amount: '75', currency: 'SEK' }, booking_date: '2026-03-01' }, // booked → kept
],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2026-01-01', '2026-06-01', mockIngest
)
const batch = mockIngest.mock.calls[0][3]
expect(batch).toHaveLength(1)
expect(batch[0].date).toBe('2026-03-01')
expect(batch[0].external_id).toBe('eb_acc-uid-1_2026-03-01_-7500_0')
})
it('does not re-import a booked transaction when a later sync returns it pending (no booking_date)', async () => {
// Regression for the 45→90 duplication. The same transaction is returned
// booked in sync 1 (booking_date 2026-04-21) and pending in sync 2 (only
// value_date 2026-02-15). Previously sync 2 ingested the pending copy with a
// value_date-derived id (different date → new id → duplicate). It must now
// be skipped, so sync 2 ingests nothing for it.
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date?: string, value_date?: string }) => ({
id: 'x',
date: tx.booking_date || tx.value_date,
booking_date: tx.booking_date || tx.value_date,
amount: -parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'AVI ÖVERDRAG',
}))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
// Sync 1 — booked
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2026-04-21', value_date: '2026-02-15' }],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2026-01-01', '2026-06-01', mockIngest
)
const firstBatch = mockIngest.mock.calls[0][3]
expect(firstBatch).toHaveLength(1)
expect(firstBatch[0].date).toBe('2026-04-21')
expect(firstBatch[0].external_id).toBe('eb_acc-uid-1_2026-04-21_-10000_0')
// Sync 2 — same transaction now returned pending (booking_date dropped)
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, value_date: '2026-02-15' }],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2026-01-01', '2026-06-01', mockIngest
)
const secondBatch = mockIngest.mock.calls[1][3]
expect(secondBatch).toHaveLength(0)
})
it('gives identical same-day same-amount transactions distinct, stable external_ids', async () => {
// Two genuinely distinct transactions that share date + amount must both be
// kept (distinct ids), and re-running the sync must reproduce the SAME set
+49 -13
View File
@@ -100,32 +100,68 @@ export async function syncAccountTransactions(
const bankTransactions = transactions.map(tx => convertTransaction(tx, account.currency))
// Derive a stable, content-based external_id per transaction. We deliberately
// do NOT key off the bank's transaction id (entry_reference/transaction_id):
// many Swedish ASPSPs regenerate those across requests, so a repeat "synka nu"
// produced a fresh id and re-imported transactions the user had already
// booked. buildStableExternalIds derives the id from (account, date, amount)
// plus an occurrence index, so re-syncs collide on (company_id, external_id)
// and dedupe while genuinely identical transactions are still kept apart.
// Normalize the IBAN (strip whitespace, uppercase) so formatting variants
// from the ASPSP ("SE45 5000 …" vs "SE455000…") don't change the scope and
// orphan every prior external_id. Falls back to the provider account uid.
// Only ingest BOOKED transactions — those the ASPSP returned with a real
// booking_date. Pending entries are intentionally skipped: a pending row is
// unstable across syncs (a later "synka nu" returns the same transaction
// either still pending or finally booked, often with a *different* effective
// date). Because BOTH the dedup external_id and the content-dedup key are
// date-derived, that drift mints a brand-new id and re-imports a transaction
// that already exists. Observed in production as the same amount+description
// landing twice with different dates — the bank's value_date in one sync, its
// booking_date in another. Gating the import set on a stable booking_date
// removes the drift at the source, and leaves booked rows' ids byte-identical
// (so the existing rows are NOT re-orphaned).
//
// booking_date is read from the RAW transaction (transactions[i]), index-
// aligned with bankTransactions: convertTransaction's booking_date already
// falls back to value_date/today, so it cannot tell booked from pending.
const bookedEntries = bankTransactions.flatMap((tx, i) => {
const bookingDate = transactions[i]?.booking_date
return typeof bookingDate === 'string' && bookingDate.trim() !== ''
? [{ tx, bookingDate: bookingDate.trim() }]
: []
})
const skippedPending = bankTransactions.length - bookedEntries.length
if (skippedPending > 0) {
console.log('[enable-banking] Skipped pending transactions (no booking_date)', {
connectionId,
accountUid: account.uid,
skippedPending,
total: bankTransactions.length,
})
}
// Derive a stable, content-based external_id per booked transaction. We
// deliberately do NOT key off the bank's transaction id (entry_reference/
// transaction_id): many Swedish ASPSPs regenerate those across requests, so a
// repeat "synka nu" produced a fresh id and re-imported transactions the user
// had already booked. buildStableExternalIds derives the id from (account,
// booking_date, amount) plus an occurrence index, so re-syncs collide on
// (company_id, external_id) and dedupe while genuinely identical transactions
// are still kept apart. Normalize the IBAN (strip whitespace, uppercase) so
// formatting variants from the ASPSP ("SE45 5000 …" vs "SE455000…") don't
// change the scope and orphan every prior external_id. Falls back to the
// provider account uid.
const accountScope = account.iban?.replace(/\s+/g, '').toUpperCase() || account.uid
const externalIds = buildStableExternalIds(
'eb',
accountScope,
bankTransactions.map((tx) => ({ date: tx.booking_date || tx.date, amount: tx.amount }))
bookedEntries.map(({ tx, bookingDate }) => ({ date: bookingDate, amount: tx.amount }))
)
// Convert Enable Banking format to generic RawTransaction. counterparty
// identification: prefer IBAN (international, normalized) over BBAN/BG
// numbers — the own-account detector matches on IBAN first, falling back
// to counterparty_account for Swedish domestic transfers.
const rawTransactions: RawTransaction[] = bankTransactions.map((tx, i) => {
const rawTransactions: RawTransaction[] = bookedEntries.map(({ tx, bookingDate }, i) => {
const cpAccount = tx.counterparty_account ?? null
const looksLikeIban = cpAccount && /^[A-Z]{2}\d/.test(cpAccount.replace(/\s+/g, ''))
return {
date: tx.booking_date || tx.date,
// The booked date is both the stable dedup anchor (see bookedEntries) and
// the accounting-correct ledger date; keep it identical to the value the
// external_id was derived from.
date: bookingDate,
// tx.description is already non-empty (convertTransaction guarantees a
// label); the trailing fallbacks are defensive. Ingest re-normalizes.
description: tx.description || tx.counterparty_name || FALLBACK_DESCRIPTION,
+21 -1
View File
@@ -1672,6 +1672,9 @@ export const invoiceInboxExtension: Extension = {
vat_code: bodyItem.vat_code || null,
vat_rate: vatRate,
vat_amount: vatAmount,
// Self-assessed RC rate (0.06/0.12/0.25) or null — engine defaults
// to 25% huvudregeln when null for a reverse-charge invoice.
reverse_charge_rate: body.reverse_charge ? (bodyItem.reverse_charge_rate ?? null) : null,
}
})
@@ -1782,7 +1785,24 @@ export const invoiceInboxExtension: Extension = {
})
}
} catch (err) {
console.error('[invoice-inbox/convert] Failed to create registration journal entry:', err)
// Engine threw (period lock, unbalanced entry, etc.) instead of
// cleanly returning null. Roll back the supplier invoice so the inbox
// item is never marked converted against an unbooked invoice (an orphan
// understating 2440/2641), then surface the error — mirroring the main
// /api/supplier-invoices route's registration catch.
await ctx.supabase
.from('supplier_invoices')
.delete()
.eq('id', invoice.id)
.eq('company_id', ctx.companyId)
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return errorResponseFromCode('SI_CREATE_FAILED', ctx.log, {
details: {
reason: err instanceof Error ? err.message : 'unknown',
step: 'registration_journal_entry',
},
})
}
}
@@ -31,9 +31,16 @@ describe('tools/list payload size guard', () => {
// closes the MCP parity gap with the existing REST endpoint so agents
// can attach a bank tx to an already-posted verifikat without creating
// duplicate bookkeeping. Description trimmed to ~180 chars.
// * 31.5K → 32K when gnubok_find_voucher_candidates_for_supplier_invoice +
// gnubok_link_supplier_invoice_to_voucher landed — the supplier-side
// mirror of the customer find/link voucher tools. The link tool inlines
// the shared STAGED_OPERATION_SCHEMA. Lets agents mark a leverantörs-
// faktura paid against an already-posted verifikat (no new bokföring),
// which is exactly the fix for invoices imported from Fortnox as open
// payables while their payment already exists in the SIE-imported GL.
// Long-term answer to growth is leaning harder on gnubok_search_tools — if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(31_500)
expect(approxTokens).toBeLessThan(32_000)
})
})
+155
View File
@@ -52,6 +52,10 @@ import {
findMatchingVouchersForInvoice,
validateVoucherForInvoiceLink,
} from '@/lib/invoices/voucher-matching'
import {
findMatchingVouchersForSupplierInvoice,
validateVoucherForSupplierInvoiceLink,
} from '@/lib/invoices/supplier-voucher-matching'
import { findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
import { closePeriod, lockPeriod, resolvePeriodStatusForDate, type PeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/bookkeeping/year-end-service'
@@ -5087,6 +5091,157 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_find_voucher_candidates_for_supplier_invoice',
description: 'List posted verifikat that debit leverantörsskuld (2440) and could be the payment for this supplier invoice. Use before gnubok_link_supplier_invoice_to_voucher when marking a leverantörsfaktura paid against an existing verifikation (no new bokföring).',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
supplier_invoice_id: { type: 'string', description: 'UUID of the supplier invoice to find candidates for' },
limit: { type: 'number', description: 'Max candidates to return (default 10, max 50)' },
},
required: ['supplier_invoice_id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
supplier_invoice_id: { type: 'string' },
invoice_status: { type: 'string' },
candidates: { type: 'array', items: { type: 'object' } },
},
required: ['supplier_invoice_id', 'candidates'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, _userId, supabase) {
const supplierInvoiceId = args.supplier_invoice_id as string
if (!supplierInvoiceId) throw new Error('supplier_invoice_id is required')
const limit = Math.min(Math.max(1, Number(args.limit) || 10), 50)
const { data: invoice, error } = await supabase
.from('supplier_invoices')
.select(
'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, supplier:suppliers(id, name)'
)
.eq('id', supplierInvoiceId)
.eq('company_id', companyId)
.single()
if (error || !invoice) throw new Error('Supplier invoice not found')
if (!['registered', 'approved', 'overdue', 'partially_paid'].includes(invoice.status)) {
return {
supplier_invoice_id: supplierInvoiceId,
invoice_status: invoice.status,
candidates: [],
}
}
const candidates = await findMatchingVouchersForSupplierInvoice(
supabase,
companyId,
invoice as never,
{ limit },
)
return {
supplier_invoice_id: supplierInvoiceId,
invoice_status: invoice.status,
candidates,
}
},
},
{
name: 'gnubok_link_supplier_invoice_to_voucher',
description: 'Markera en leverantörsfaktura som betald genom att länka till en befintlig verifikation som redan debiterar leverantörsskuld (2440). Ingen ny verifikation skapas. Hitta kandidater med gnubok_find_voucher_candidates_for_supplier_invoice först.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
supplier_invoice_id: { type: 'string', description: 'UUID of the supplier invoice to mark paid' },
journal_entry_id: { type: 'string', description: 'UUID of the existing posted verifikat to link' },
notes: { type: 'string', description: 'Optional note stored on the supplier_invoice_payments row' },
},
required: ['supplier_invoice_id', 'journal_entry_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const supplierInvoiceId = args.supplier_invoice_id as string
const journalEntryId = args.journal_entry_id as string
const notes = (args.notes as string | undefined) ?? undefined
if (!supplierInvoiceId || !journalEntryId) {
throw new Error('supplier_invoice_id and journal_entry_id are required')
}
const { data: invoice, error: invErr } = await supabase
.from('supplier_invoices')
.select(
'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, supplier:suppliers(id, name)'
)
.eq('id', supplierInvoiceId)
.eq('company_id', companyId)
.single()
if (invErr || !invoice) throw new Error('Supplier invoice not found')
if (!['registered', 'approved', 'overdue', 'partially_paid'].includes(invoice.status)) {
throw new Error('Supplier invoice is not in a matchable state (must be registered, approved, overdue, or partially_paid)')
}
const validation = await validateVoucherForSupplierInvoiceLink(
supabase,
companyId,
invoice as never,
journalEntryId,
)
if (!validation.ok) {
throw new Error(
`${validation.code}${validation.details ? `: ${JSON.stringify(validation.details)}` : ''}`,
)
}
const voucherLabel = validation.voucher.voucher_series && validation.voucher.voucher_number != null
? `${validation.voucher.voucher_series}-${validation.voucher.voucher_number}`
: journalEntryId.slice(0, 8)
return stagePendingOperation(
supabase,
companyId,
userId,
'link_supplier_invoice_voucher',
`Länka verifikat ${voucherLabel} → leverantörsfaktura ${invoice.supplier_invoice_number ?? supplierInvoiceId.slice(0, 8)}`,
{ supplier_invoice_id: supplierInvoiceId, journal_entry_id: journalEntryId, notes },
{
supplier_invoice_number: invoice.supplier_invoice_number,
invoice_currency: invoice.currency,
invoice_remaining: invoice.remaining_amount,
voucher_label: voucherLabel,
voucher_date: validation.voucher.entry_date,
voucher_description: validation.voucher.description,
ap_debit_amount: validation.apDebitAmount,
payment_amount: validation.paymentAmount,
will_be_fully_paid: validation.isFullyPaid,
remaining_after: validation.remainingAfter,
supplier_name: (invoice.supplier as unknown as { name?: string } | null)?.name ?? null,
},
actor,
{
description: 'After approval the supplier invoice transitions to paid (or partially_paid). No new verifikat is created — the existing voucher is the payment posting.',
tool: 'gnubok_get_supplier_ledger',
},
)
},
},
{
name: 'gnubok_auto_match_period',
description: "Bulk reconciliation: scan unmatched income transactions in a date range and propose invoice matches with confidence + reasoning. dry_run=true (default) previews without staging; dry_run=false stages every match above confidence_threshold as a pending operation.",
+10
View File
@@ -355,6 +355,16 @@ export const CreateSupplierInvoiceItemSchema = z.object({
// currency rounding, or POS receipts where supplier-side rounding makes the
// VAT off by öre.
vat_amount: z.number().min(0).optional(),
// Self-assessed VAT rate for omvänd skattskyldighet (reverse charge). The
// supplier charges no VAT (vat_rate stays 0); this is the Swedish statutory
// rate the buyer self-assesses at — 25% huvudregel default, 12%/6% for
// reduced-rated services (ML 6 kap 34 §). Must be a statutory rate.
reverse_charge_rate: z
.number()
.refine((r) => r === 0.06 || r === 0.12 || r === 0.25, {
message: 'reverse_charge_rate must be 0.06, 0.12, or 0.25',
})
.optional(),
vat_code: z.string().optional(),
quantity: z.number().optional(),
unit: z.string().optional(),
+5 -2
View File
@@ -13,8 +13,8 @@ export const API_KEY_SCOPES = {
'customers:write': { label: 'Kunder — skriv', description: 'Skapa kunder (1 verktyg)' },
'invoices:read': { label: 'Fakturor — läs', description: 'Lista fakturor (1 verktyg)' },
'invoices:write': { label: 'Fakturor — skriv', description: 'Skapa, skicka, markera betald/skickad (4 verktyg)' },
'suppliers:read': { label: 'Leverantörer — läs', description: 'Lista leverantörer och leverantörsfakturor (2 verktyg)' },
'suppliers:write': { label: 'Leverantörer — skriv', description: 'Godkänn och kreditera leverantörsfakturor (2 verktyg)' },
'suppliers:read': { label: 'Leverantörer — läs', description: 'Lista leverantörer och leverantörsfakturor, hitta verifikat-kandidater (3 verktyg)' },
'suppliers:write': { label: 'Leverantörer — skriv', description: 'Skapa leverantörer; godkänn, kreditera, betal-länka och hantera leverantörsfakturor (6 verktyg)' },
'reports:read': { label: 'Rapporter — läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export (12 verktyg)' },
'bookkeeping:write': { label: 'Bokföring — skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar' },
'payroll:read': { label: 'Löner — läs', description: 'Lista anställda, lönekörningar, lönejournal (3 verktyg)' },
@@ -212,6 +212,9 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_credit_supplier_invoice: 'suppliers:write',
gnubok_create_supplier_invoice_from_inbox: 'suppliers:write',
gnubok_set_inbox_extracted_data: 'suppliers:write',
// Supplier invoice payment via existing verifikat (no new bokföring)
gnubok_find_voucher_candidates_for_supplier_invoice: 'suppliers:read',
gnubok_link_supplier_invoice_to_voucher: 'suppliers:write',
// Invoice conversion + crediting
gnubok_convert_invoice: 'invoices:write',
gnubok_credit_invoice: 'invoices:write',
@@ -36,9 +36,14 @@ vi.mock('../currency-utils', () => ({
),
}))
// Mock vat-entries with real reverse charge logic
vi.mock('../vat-entries', () => ({
generateReverseChargeLines: vi.fn().mockImplementation(
// Mock vat-entries: keep the real pure helpers (resolveReverseChargeRate,
// isReverseChargeBasisAccount, RC_BASIS_ACCOUNTS) and stub only the two
// line-builders with simplified logic the assertions below rely on.
vi.mock('../vat-entries', async (importOriginal) => {
const actual = await importOriginal<typeof import('../vat-entries')>()
return {
...actual,
generateReverseChargeLines: vi.fn().mockImplementation(
(baseAmount: number, vatRate: number = 0.25, isDomestic: boolean = false) => {
const vatAmount = Math.round(baseAmount * vatRate * 100) / 100
const inputAccount = isDomestic ? '2647' : '2645'
@@ -72,7 +77,8 @@ vi.mock('../vat-entries', () => ({
]
}
),
}))
}
})
const { createJournalEntry, findFiscalPeriod } = await import('../engine')
const mockedCreateEntry = vi.mocked(createJournalEntry)
@@ -107,6 +113,7 @@ function makeItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoice
vat_code: null,
vat_rate: vatRate,
vat_amount: vatAmount,
reverse_charge_rate: null,
created_at: '2024-06-01T00:00:00Z',
...overrides,
}
@@ -371,6 +378,78 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
assertBalanced(input)
})
it('books reverse charge VAT for a 0%-rate line item — defaults to 25% huvudregeln (regression)', async () => {
// The exact reported bug: a Finnish (EU) supplier invoice entered with the
// line at 0% momssats (the supplier charges no VAT) must still self-assess
// at 25%. Before the fix the `rate > 0` guard skipped ALL VAT lines, so the
// verifikat was just expense + 2440 — the user had to add VAT lines by hand.
const invoice = makeSupplierInvoice({
subtotal: 12000,
vat_amount: 0,
total: 12000,
reverse_charge: true,
})
const items = [makeItem({ line_total: 12000, account_number: '5910', vat_rate: 0, reverse_charge_rate: null })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '5910')[0].debit_amount).toBe(12000)
// Fiktiv moms self-assessed at the 25% huvudregel default (ruta 30 / 48).
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(3000)
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(3000)
// Basbeloppsrader for ruta 21 (EU services) — required or SKV rejects FK004.
expect(findByAccount(input.lines, '4535')[0].debit_amount).toBe(12000)
expect(findByAccount(input.lines, '4598')[0].credit_amount).toBe(12000)
// Leverantörsskuld is the net (no VAT rolls into the payable under RC).
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(12000)
assertBalanced(input)
})
it('honours an explicit reverse_charge_rate (12%) on a 0%-rate line item', async () => {
const invoice = makeSupplierInvoice({
subtotal: 10000, vat_amount: 0, total: 10000, reverse_charge: true,
})
const items = [makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0, reverse_charge_rate: 0.12 })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(1200) // 10000 * 0.12
expect(findByAccount(input.lines, '2624')[0].credit_amount).toBe(1200) // ruta 31
expect(findByAccount(input.lines, '4536')[0].debit_amount).toBe(10000) // ruta 21 @ 12%
// 25% accounts must NOT appear when the self-assessed rate is 12%.
expect(findByAccount(input.lines, '2614')).toHaveLength(0)
expect(findByAccount(input.lines, '4535')).toHaveLength(0)
assertBalanced(input)
})
it('honours an explicit reverse_charge_rate (6%) on a 0%-rate line item', async () => {
const invoice = makeSupplierInvoice({
subtotal: 10000, vat_amount: 0, total: 10000, reverse_charge: true,
})
const items = [makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0, reverse_charge_rate: 0.06 })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(600) // 10000 * 0.06
expect(findByAccount(input.lines, '2634')[0].credit_amount).toBe(600) // ruta 32
expect(findByAccount(input.lines, '4537')[0].debit_amount).toBe(10000) // ruta 21 @ 6%
// Higher-rate accounts must NOT appear when the self-assessed rate is 6%.
expect(findByAccount(input.lines, '2614')).toHaveLength(0)
expect(findByAccount(input.lines, '2624')).toHaveLength(0)
expect(findByAccount(input.lines, '4535')).toHaveLength(0)
expect(findByAccount(input.lines, '4536')).toHaveLength(0)
assertBalanced(input)
})
it('books non-EU services to 4531 (ruta 22) and motkonto 4598', async () => {
const invoice = makeSupplierInvoice({
subtotal: 8000,
@@ -1038,6 +1117,24 @@ describe('createSupplierInvoiceCashEntry', () => {
assertBalanced(input)
})
it('EU reverse charge with a 0%-rate line item self-assesses at 25% (regression)', async () => {
const invoice = makeSupplierInvoice({
subtotal: 12000, vat_amount: 0, total: 12000, reverse_charge: true,
})
const items = [makeItem({ line_total: 12000, account_number: '5910', vat_rate: 0, reverse_charge_rate: null })]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(3000)
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(3000)
expect(findByAccount(input.lines, '4535')[0].debit_amount).toBe(12000)
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(12000)
assertBalanced(input)
})
it('has no 2440 line', async () => {
const invoice = makeSupplierInvoice()
const items = [makeItem()]
@@ -1228,6 +1325,32 @@ describe('createSupplierCreditNoteEntry', () => {
assertBalanced(input)
})
it('reverses a 0%-rate reverse charge credit note at the 25% default (regression)', async () => {
// A credit note for the buggy 0%-rate RC invoice must reverse the same
// self-assessed VAT the registration booked, or it leaves ruta 21/30/48
// half-cancelled. The credit-note path resolves the same 25% default.
const creditNote = makeSupplierInvoice({
is_credit_note: true,
subtotal: -12000,
vat_amount: 0,
total: -12000,
reverse_charge: true,
})
const items = [makeItem({ line_total: -12000, account_number: '5910', vat_rate: 0, reverse_charge_rate: null })]
await createSupplierCreditNoteEntry(
null as never, 'company-1', 'user-1', creditNote, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2645')[0].credit_amount).toBe(3000)
expect(findByAccount(input.lines, '2614')[0].debit_amount).toBe(3000)
expect(findByAccount(input.lines, '4535')[0].credit_amount).toBe(12000)
expect(findByAccount(input.lines, '4598')[0].debit_amount).toBe(12000)
expect(findByAccount(input.lines, '2440')[0].debit_amount).toBe(12000)
assertBalanced(input)
})
it('uses Math.abs for all amounts (negative inputs produce positive lines)', async () => {
const creditNote = makeSupplierInvoice({
is_credit_note: true,
+18 -30
View File
@@ -1,6 +1,11 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { generateReverseChargeLines, generateReverseChargeBasisLines } from './vat-entries'
import {
generateReverseChargeLines,
generateReverseChargeBasisLines,
isReverseChargeBasisAccount,
resolveReverseChargeRate,
} from './vat-entries'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
@@ -13,29 +18,6 @@ import type {
const log = createLogger('supplier-invoice-entries')
/**
* Accounts that already populate momsdeklaration ruta 20-24 directly when
* debited. If the user picked one of these as the expense account on an RC
* invoice item, the engine must NOT add the parallel basbeloppsrader (those
* would double-count the basis).
*/
const RC_BASIS_ACCOUNTS = new Set([
// ruta 20 — EU goods
'4515', '4516', '4517',
// ruta 21 — EU services
'4535', '4536', '4537',
// ruta 22 — non-EU services
'4531', '4532', '4533',
// ruta 23 — domestic goods RC
'4415', '4416', '4417',
// ruta 24 — domestic services RC
'4425', '4426', '4427',
])
function isBasisAccount(account: string): boolean {
return RC_BASIS_ACCOUNTS.has(account)
}
/**
* Build a BFL-compliant verifikation description with event type, counterparty, and suffix.
* Falls back to prefix + invoiceNumber + suffix if name is not provided (backward compat).
@@ -662,9 +644,15 @@ function groupVatByRate(
}
/**
* Group items by VAT rate and sum the base (line_total) per rate.
* Used by reverse-charge paths to compute fiktiv moms from the basis,
* decoupled from any manual VAT override on the items themselves.
* Group items by their self-assessed reverse-charge rate and sum the base
* (line_total) per rate. Used by reverse-charge paths to compute fiktiv moms
* from the basis, decoupled from any manual VAT override on the items.
*
* The grouping key is the *self-assessed* rate (resolveReverseChargeRate), not
* the line's vat_rate: under omvänd skattskyldighet the supplier charges 0%, so
* the line vat_rate is 0, but the buyer self-assesses at 25% (huvudregeln) or
* the explicit per-item reverse_charge_rate. Without this a 0%-rate RC line
* would key on rate 0 and the `rate > 0` guard below would skip its VAT lines.
*/
function groupBaseByRate(
items: SupplierInvoiceItem[],
@@ -674,7 +662,7 @@ function groupBaseByRate(
): Map<number, number> {
const baseByRate = new Map<number, number>()
for (const item of items) {
const rate = item.vat_rate ?? 0.25
const rate = resolveReverseChargeRate(item)
let baseSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
if (useAbsoluteValues) baseSek = Math.abs(baseSek)
baseByRate.set(rate, (baseByRate.get(rate) || 0) + baseSek)
@@ -696,8 +684,8 @@ function groupNonBasisBaseByRate(
): Map<number, number> {
const baseByRate = new Map<number, number>()
for (const item of items) {
if (isBasisAccount(item.account_number)) continue
const rate = item.vat_rate ?? 0.25
if (isReverseChargeBasisAccount(item.account_number)) continue
const rate = resolveReverseChargeRate(item)
let itemSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
if (useAbsoluteValues) itemSek = Math.abs(itemSek)
baseByRate.set(rate, (baseByRate.get(rate) || 0) + itemSek)
+51
View File
@@ -38,6 +38,57 @@ export function getVatRate(treatment: VatTreatment): number {
}
}
/**
* Expense/basis accounts that already populate momsdeklaration ruta 20-24
* directly when debited (the basbelopp for a reverse-charge purchase). If an RC
* item is booked straight to one of these, the engine must NOT add the parallel
* basbeloppsrader that would double-count ruta 20-24.
*
* ruta 20 EU goods 4515/4516/4517
* ruta 21 EU services 4535/4536/4537
* ruta 22 non-EU services 4531/4532/4533
* ruta 23 domestic goods RC 4415/4416/4417
* ruta 24 domestic services RC 4425/4426/4427
*/
export const RC_BASIS_ACCOUNTS: ReadonlySet<string> = new Set([
'4515', '4516', '4517',
'4535', '4536', '4537',
'4531', '4532', '4533',
'4415', '4416', '4417',
'4425', '4426', '4427',
])
export function isReverseChargeBasisAccount(account: string): boolean {
return RC_BASIS_ACCOUNTS.has(account)
}
/**
* The self-assessed VAT rate to apply to a reverse-charge line.
*
* Under omvänd skattskyldighet the supplier charges no VAT, so the line's own
* `vat_rate` is 0 (the v1 supplier-invoice API mandates this). The buyer must
* still self-assess output + input VAT at the Swedish statutory rate that would
* apply to the service domestically 25% under huvudregeln for EU services
* (ML 6 kap 34 §), 12%/6% for reduced-rated services. Resolution order:
*
* 1. explicit per-item `reverse_charge_rate` (the UI's self-assessment picker)
* 2. a positive `vat_rate` on the line (legacy/API callers that encoded the
* self-assessment rate directly on vat_rate)
* 3. 25% huvudregel default never silently drop the fiktiv-moms lines.
*
* Keeping this in one place means the booking engine and the review-dialog
* preview can never drift. The original bug was two independent copies of a
* `rate > 0` assumption, each skipping the VAT entirely on a 0%-rate RC line.
*/
export function resolveReverseChargeRate(
item: { vat_rate?: number | null; reverse_charge_rate?: number | null },
): number {
const explicit = item.reverse_charge_rate
if (explicit != null && explicit > 0) return explicit
if (item.vat_rate != null && item.vat_rate > 0) return item.vat_rate
return 0.25
}
/**
* Generate output VAT lines for sales invoices
* Debit 1510 Kundfordringar [total incl VAT]
@@ -0,0 +1,219 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock the two dependencies so we test the ORCHESTRATION logic (confidence
// gating, near-tie margin, cross-invoice voucher exclusivity, consumed-voucher
// filtering) in isolation. The matcher + RPC link are exercised by their own
// suites (supplier-voucher-matching.test.ts / .pg.test.ts).
vi.mock('@/lib/supabase/fetch-all', () => ({ fetchAllRows: vi.fn() }))
vi.mock('../supplier-voucher-matching', () => ({
findMatchingVouchersForSupplierInvoice: vi.fn(),
linkSupplierInvoiceToVoucher: vi.fn(),
}))
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
findMatchingVouchersForSupplierInvoice,
linkSupplierInvoiceToVoucher,
} from '../supplier-voucher-matching'
import { reconcileSupplierInvoiceVouchers } from '../bulk-reconcile-supplier-vouchers'
const mFetchAll = vi.mocked(fetchAllRows)
const mFind = vi.mocked(findMatchingVouchersForSupplierInvoice)
const mLink = vi.mocked(linkSupplierInvoiceToVoucher)
interface InvOver {
id: string
number?: string
status?: string
total?: number
remaining?: number
due?: string
isCredit?: boolean
}
function inv(over: InvOver) {
const total = over.total ?? 1000
return {
id: over.id,
supplier_invoice_number: over.number ?? `F-${over.id}`,
arrival_number: 1,
status: over.status ?? 'overdue',
currency: 'SEK',
total,
paid_amount: 0,
remaining_amount: over.remaining ?? total,
due_date: over.due ?? '2026-02-01',
paid_at: null,
exchange_rate: null,
supplier_id: 's1',
is_credit_note: over.isCredit ?? false,
supplier: { id: 's1', name: 'Leverantör AB' },
}
}
function cand(over: { je: string; confidence?: number; amount?: number; n?: number }) {
return {
journal_entry_id: over.je,
voucher_series: 'A',
voucher_number: over.n ?? 1,
entry_date: '2026-02-01',
description: 'Leverantörsbetalning',
ap_debit_amount: over.amount ?? 1000,
currency: 'SEK',
ap_line_currency: 'SEK',
period_locked: false,
confidence: over.confidence ?? 0.95,
match_reason: 'test',
}
}
/** Queue the two fetchAllRows reads: invoices, then existing payments. */
function queue(invoices: unknown[], payments: { journal_entry_id: string | null }[] = []) {
mFetchAll.mockReset()
mFetchAll.mockResolvedValueOnce(invoices as never).mockResolvedValueOnce(payments as never)
}
const okLink = (over: { paymentAmount?: number; status?: 'paid' | 'partially_paid'; je: string }) => ({
ok: true as const,
result: {
paymentId: 'p1',
invoiceStatus: over.status ?? ('paid' as const),
paidAmount: 1000,
remainingAmount: 0,
paymentAmount: over.paymentAmount ?? 1000,
journalEntryId: over.je,
},
})
const run = () =>
reconcileSupplierInvoiceVouchers({ supabase: {} as never, companyId: 'c1', userId: 'u1' })
describe('reconcileSupplierInvoiceVouchers', () => {
beforeEach(() => {
vi.clearAllMocks()
mFind.mockReset()
mLink.mockReset()
})
it('auto-links a single unambiguous exact match and marks it paid', async () => {
queue([inv({ id: 'i1', remaining: 1000 })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
mLink.mockResolvedValueOnce(okLink({ je: 'v1' }) as never)
const res = await run()
expect(res.scanned).toBe(1)
expect(res.autoLinked).toBe(1)
expect(res.ambiguous).toBe(0)
expect(res.unmatched).toBe(0)
expect(res.links).toHaveLength(1)
expect(res.links[0]).toMatchObject({ journal_entry_id: 'v1', invoice_status: 'paid' })
expect(mLink).toHaveBeenCalledTimes(1)
expect(mLink).toHaveBeenCalledWith({} , 'u1', 'c1', expect.objectContaining({
supplierInvoiceId: 'i1',
journalEntryId: 'v1',
}))
})
it('does not auto-link a below-threshold (amount-only) candidate', async () => {
queue([inv({ id: 'i1' })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.8, amount: 1000 })] as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.ambiguous).toBe(1)
expect(res.review[0]).toMatchObject({ supplier_invoice_id: 'i1', reason: 'low_confidence' })
expect(mLink).not.toHaveBeenCalled()
})
it('does not auto-link when the top two candidates are within the margin', async () => {
queue([inv({ id: 'i1' })])
mFind.mockResolvedValueOnce([
cand({ je: 'v1', confidence: 0.95, amount: 1000 }),
cand({ je: 'v2', confidence: 0.95, amount: 1000, n: 2 }),
] as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.ambiguous).toBe(1)
expect(res.review[0].reason).toBe('multiple_candidates')
expect(mLink).not.toHaveBeenCalled()
})
it('demotes BOTH invoices when one voucher is the top pick for two of them', async () => {
queue([inv({ id: 'i1', remaining: 1000 }), inv({ id: 'i2', remaining: 1000 })])
// Each invoice has exactly one strong candidate — but it is the SAME voucher.
mFind
.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.ambiguous).toBe(2)
expect(res.review.every((r) => r.reason === 'voucher_contested')).toBe(true)
expect(mLink).not.toHaveBeenCalled()
})
it('excludes a voucher already consumed as a payment on another invoice', async () => {
queue([inv({ id: 'i1' })], [{ journal_entry_id: 'v1' }])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.99, amount: 1000 })] as never)
const res = await run()
expect(res.unmatched).toBe(1)
expect(res.autoLinked).toBe(0)
expect(mLink).not.toHaveBeenCalled()
})
it('routes a candidate whose AP debit exceeds the remaining to review', async () => {
queue([inv({ id: 'i1', total: 1000, remaining: 500 })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.99, amount: 1000 })] as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.review[0].reason).toBe('amount_exceeds_remaining')
expect(mLink).not.toHaveBeenCalled()
})
it('dryRun produces the plan without writing', async () => {
queue([inv({ id: 'i1', remaining: 1000 })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
const res = await reconcileSupplierInvoiceVouchers({
supabase: {} as never,
companyId: 'c1',
userId: 'u1',
dryRun: true,
})
expect(res.autoLinked).toBe(1)
expect(res.links[0]).toMatchObject({ journal_entry_id: 'v1', invoice_status: 'paid' })
expect(mLink).not.toHaveBeenCalled()
})
it('skips credit notes and zero-remaining invoices entirely', async () => {
queue([inv({ id: 'i1', isCredit: true }), inv({ id: 'i2', remaining: 0 })])
const res = await run()
expect(res.scanned).toBe(0)
expect(res.autoLinked).toBe(0)
expect(mFind).not.toHaveBeenCalled()
})
it('surfaces an RPC rejection as review rather than a successful link', async () => {
queue([inv({ id: 'i1', remaining: 1000 })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
mLink.mockResolvedValueOnce({ ok: false, code: 'LINK_SI_VOUCHER_ALREADY_LINKED' } as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.ambiguous).toBe(1)
expect(res.review[0].reason).toBe('voucher_contested')
})
})
@@ -0,0 +1,370 @@
/**
* Bulk reconcile supplier invoices to already-posted GL payment vouchers.
*
* Context: when a company is migrated from another system (e.g. Fortnox via the
* arcim-migration extension), the general ledger including the bank-payment
* vouchers that settle accounts payable (Dr 2440 / Cr 1930) is imported
* separately via SIE. Supplier invoices are imported as standalone
* `supplier_invoices` rows with NO link to those vouchers (the entity mapper
* never sets `payment_journal_entry_id`). Fortnox is queried with
* `?filter=unpaid`, so an invoice whose payment was booked in the source GL but
* never registered against the leverantörsfaktura object arrives here as an
* open payable. Once its due date passes the nightly cron flips it to
* `overdue` even though the settling voucher already exists in the GL.
*
* This pass links each open payable to its matching posted voucher (reusing the
* exact same matcher + RPC behind the manual "Markera som betald Befintlig
* verifikation" UI flow), so genuinely-settled invoices show as paid instead of
* falsely overdue. It NEVER creates, edits, or deletes a journal entry it only
* inserts a `supplier_invoice_payments` row pointing at the existing voucher and
* advances the invoice's paid/remaining/status (all via the atomic
* `link_supplier_invoice_to_voucher` RPC).
*
* Safety: auto-linking is intentionally conservative. A voucher is linked
* automatically only when the match is unambiguous (see AUTO_LINK_* constants
* and the uniqueness rules below). Everything else is surfaced for manual review
* rather than guessed at. The function is idempotent and order-independent it
* can be re-run any time after both halves of a migration exist.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
findMatchingVouchersForSupplierInvoice,
linkSupplierInvoiceToVoucher,
type SupplierVoucherCandidate,
} from './supplier-voucher-matching'
import type { SupplierInvoice, Supplier } from '@/types'
const log = createLogger('bulk-reconcile-supplier-vouchers')
/**
* Minimum confidence for an UNATTENDED auto-link. 0.95 = OCR/invoice-number hit
* (0.99) or exact-remaining-amount AND supplier-name corroboration (0.95).
* Amount-only matches (0.80, even with the +0.05 date bump 0.85) are
* deliberately excluded too many invoices share round amounts.
*/
const AUTO_LINK_MIN_CONFIDENCE = 0.95
/**
* Required confidence gap between the top candidate and the runner-up. A near
* tie means two vouchers look equally plausible not safe to auto-pick. A
* margin (not exact equality) absorbs the ±0.05 date-proximity perturbation.
*/
const AUTO_LINK_MIN_MARGIN = 0.1
/** 0.5 öre — mirrors the tolerance used across the matching/RPC paths. */
const AMOUNT_TOLERANCE = 0.005
/** Safety cap on invoices processed in a single run (Vercel 300s budget). */
const DEFAULT_MAX_INVOICES = 2000
/** Supplier-invoice statuses that represent an open payable. */
const PAYABLE_STATUSES = ['registered', 'approved', 'overdue', 'partially_paid']
type ReconcileInvoiceRow = SupplierInvoice & {
is_credit_note?: boolean | null
supplier?: { id: string; name: string } | null
}
export type ReconcileReviewReason =
| 'multiple_candidates' // ≥2 candidates within the auto-link margin
| 'low_confidence' // best candidate below AUTO_LINK_MIN_CONFIDENCE
| 'amount_exceeds_remaining' // best candidate would overpay the invoice
| 'voucher_contested' // one voucher is the top pick for >1 invoice, or RPC rejected
export interface ReconcileLink {
supplier_invoice_id: string
supplier_invoice_number: string | null
journal_entry_id: string
payment_amount: number
invoice_status: 'paid' | 'partially_paid'
confidence: number
match_reason: string
}
export interface ReconcileReviewItem {
supplier_invoice_id: string
supplier_invoice_number: string | null
reason: ReconcileReviewReason
candidates: SupplierVoucherCandidate[]
}
export interface ReconcileResult {
/** Open payables considered (after credit-note / zero-remaining filtering). */
scanned: number
/** Invoices auto-linked to a voucher (or that would be, when dryRun). */
autoLinked: number
/** Invoices with candidate(s) but not safe to auto-link — need manual review. */
ambiguous: number
/** Invoices with no eligible voucher candidate at all. */
unmatched: number
/** True when more payables existed than `maxInvoices` and the rest were skipped. */
capped: boolean
links: ReconcileLink[]
review: ReconcileReviewItem[]
}
export interface ReconcileOptions {
supabase: SupabaseClient
companyId: string
/** Real user id — written onto the supplier_invoice_payments row + emitted event. */
userId: string
/** Compute the plan without writing. Default false. */
dryRun?: boolean
/** Max invoices to process in one run. Default 2000. */
maxInvoices?: number
onProgress?: (done: number, total: number) => void
}
const SELECT_COLUMNS =
'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, is_credit_note, supplier:suppliers(id, name)'
function remainingOf(inv: ReconcileInvoiceRow): number {
if (typeof inv.remaining_amount === 'number') return Math.max(0, inv.remaining_amount)
return Math.max(0, Math.round((inv.total - (inv.paid_amount ?? 0)) * 100) / 100)
}
/**
* Link open supplier-invoice payables to their matching already-posted GL
* vouchers. See file header for the full rationale and guarantees.
*/
export async function reconcileSupplierInvoiceVouchers(
opts: ReconcileOptions,
): Promise<ReconcileResult> {
const { supabase, companyId, userId, dryRun = false } = opts
const maxInvoices = opts.maxInvoices ?? DEFAULT_MAX_INVOICES
const result: ReconcileResult = {
scanned: 0,
autoLinked: 0,
ambiguous: 0,
unmatched: 0,
capped: false,
links: [],
review: [],
}
// 1. Open payables with an outstanding balance, excluding credit notes.
// Deterministic order so re-runs and the cross-invoice uniqueness pass are
// stable. Fully-paid invoices ('paid') are excluded by the status filter,
// making re-runs naturally idempotent.
const invoices = await fetchAllRows<ReconcileInvoiceRow>(
({ from, to }) =>
supabase
.from('supplier_invoices')
.select(SELECT_COLUMNS)
.eq('company_id', companyId)
.in('status', PAYABLE_STATUSES)
.order('due_date', { ascending: true })
.order('id', { ascending: true })
.range(from, to) as unknown as PromiseLike<{
// The `supplier:suppliers(id, name)` join makes PostgREST infer `supplier`
// as an array; ReconcileInvoiceRow models the runtime single-object shape.
data: ReconcileInvoiceRow[] | null
error: { message: string } | null
}>,
)
const payables = invoices.filter(
(inv) => !inv.is_credit_note && remainingOf(inv) > AMOUNT_TOLERANCE,
)
const toProcess = payables.slice(0, maxInvoices)
if (payables.length > maxInvoices) {
result.capped = true
log.warn('reconcile capped to maxInvoices — remaining payables left for a later run', {
companyId,
totalPayables: payables.length,
cap: maxInvoices,
})
}
// 2. Pre-load every voucher already consumed as a supplier payment (for ANY
// invoice in the company). Neither the matcher nor the RPC stop the SAME
// voucher being linked to a SECOND invoice, so we enforce exclusivity here.
const existingPayments = await fetchAllRows<{ journal_entry_id: string | null }>(({ from, to }) =>
supabase
.from('supplier_invoice_payments')
.select('journal_entry_id')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.range(from, to),
)
const consumedVouchers = new Set(
existingPayments
.map((p) => p.journal_entry_id)
.filter((id): id is string => !!id),
)
// 3. Per-invoice candidate gathering (read-only). Decide auto-eligibility.
interface Plan {
invoice: ReconcileInvoiceRow
candidates: SupplierVoucherCandidate[]
top?: SupplierVoucherCandidate
}
const autoCandidatePlans: Plan[] = []
for (const invoice of toProcess) {
result.scanned++
const candidates = await findMatchingVouchersForSupplierInvoice(
supabase,
companyId,
invoice as unknown as SupplierInvoice & { supplier?: Supplier },
{ limit: 5 },
)
// Drop vouchers already used elsewhere in the company.
const fresh = candidates.filter((c) => !consumedVouchers.has(c.journal_entry_id))
if (fresh.length === 0) {
result.unmatched++
continue
}
const top = fresh[0]
const runnerUp = fresh[1]
const remaining = remainingOf(invoice)
const confidentEnough = top.confidence >= AUTO_LINK_MIN_CONFIDENCE
const clearMargin = !runnerUp || top.confidence - runnerUp.confidence >= AUTO_LINK_MIN_MARGIN
// The RPC rejects a voucher whose AP debit exceeds the remaining amount; an
// OCR match (which ignores amount) could trip this, so screen it out here.
const amountFits = top.ap_debit_amount <= remaining + AMOUNT_TOLERANCE
if (confidentEnough && clearMargin && amountFits) {
autoCandidatePlans.push({ invoice, candidates: fresh, top })
} else {
result.ambiguous++
result.review.push({
supplier_invoice_id: invoice.id,
supplier_invoice_number: invoice.supplier_invoice_number ?? null,
reason: !confidentEnough
? 'low_confidence'
: !amountFits
? 'amount_exceeds_remaining'
: 'multiple_candidates',
candidates: fresh,
})
}
}
// 4. Cross-invoice uniqueness: if one voucher is the top auto-pick for more
// than one invoice (e.g. two identical 5 000 kr invoices both grabbing the
// same 5 000 kr voucher), auto-link NONE of them — demote all to review.
const claimsByVoucher = new Map<string, Plan[]>()
for (const plan of autoCandidatePlans) {
const key = plan.top!.journal_entry_id
const arr = claimsByVoucher.get(key) ?? []
arr.push(plan)
claimsByVoucher.set(key, arr)
}
const safePlans: Plan[] = []
for (const claimants of claimsByVoucher.values()) {
if (claimants.length === 1) {
safePlans.push(claimants[0])
} else {
for (const c of claimants) {
result.ambiguous++
result.review.push({
supplier_invoice_id: c.invoice.id,
supplier_invoice_number: c.invoice.supplier_invoice_number ?? null,
reason: 'voucher_contested',
candidates: c.candidates,
})
}
}
}
// 5. Link the unambiguous plans. Deterministic order; respect exclusivity
// across the batch via consumedVouchers.
safePlans.sort(
(a, b) =>
(a.invoice.due_date ?? '').localeCompare(b.invoice.due_date ?? '') ||
a.invoice.id.localeCompare(b.invoice.id),
)
let done = 0
for (const plan of safePlans) {
const top = plan.top!
const remaining = remainingOf(plan.invoice)
// Defensive: a voucher consumed earlier in THIS batch is off-limits.
if (consumedVouchers.has(top.journal_entry_id)) {
result.ambiguous++
result.review.push({
supplier_invoice_id: plan.invoice.id,
supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null,
reason: 'voucher_contested',
candidates: plan.candidates,
})
continue
}
if (dryRun) {
const willBeFullyPaid = top.ap_debit_amount >= remaining - AMOUNT_TOLERANCE
result.autoLinked++
result.links.push({
supplier_invoice_id: plan.invoice.id,
supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null,
journal_entry_id: top.journal_entry_id,
payment_amount: Math.min(top.ap_debit_amount, remaining),
invoice_status: willBeFullyPaid ? 'paid' : 'partially_paid',
confidence: top.confidence,
match_reason: top.match_reason,
})
consumedVouchers.add(top.journal_entry_id)
done++
opts.onProgress?.(done, safePlans.length)
continue
}
const outcome = await linkSupplierInvoiceToVoucher(supabase, userId, companyId, {
supplierInvoiceId: plan.invoice.id,
journalEntryId: top.journal_entry_id,
notes: `Auto-länkad vid avstämning (${Math.round(top.confidence * 100)}% säkerhet): ${top.match_reason}`,
})
if (outcome.ok) {
result.autoLinked++
consumedVouchers.add(top.journal_entry_id)
result.links.push({
supplier_invoice_id: plan.invoice.id,
supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null,
journal_entry_id: top.journal_entry_id,
payment_amount: outcome.result.paymentAmount,
invoice_status: outcome.result.invoiceStatus,
confidence: top.confidence,
match_reason: top.match_reason,
})
} else {
// The RPC re-validates atomically; a rejection here (race, already-linked,
// amount drift) means it isn't a clean auto-link — surface it.
result.ambiguous++
result.review.push({
supplier_invoice_id: plan.invoice.id,
supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null,
reason: 'voucher_contested',
candidates: plan.candidates,
})
log.warn('auto-link rejected by RPC', {
companyId,
supplierInvoiceId: plan.invoice.id,
journalEntryId: top.journal_entry_id,
code: outcome.code,
})
}
done++
opts.onProgress?.(done, safePlans.length)
}
log.info('reconcile complete', {
companyId,
dryRun,
scanned: result.scanned,
autoLinked: result.autoLinked,
ambiguous: result.ambiguous,
unmatched: result.unmatched,
capped: result.capped,
})
return result
}
@@ -0,0 +1,128 @@
/**
* Unit tests for commitLinkSupplierInvoiceVoucher, driven through the public
* commitPendingOperation dispatcher.
*
* The MCP tool gnubok_link_supplier_invoice_to_voucher stages a
* 'link_supplier_invoice_voucher' pending_operation; this dispatcher picks it up
* and the executor delegates to linkSupplierInvoiceToVoucher (the atomic
* link_supplier_invoice_to_voucher RPC). The RPC itself is covered by
* lib/invoices/__tests__/supplier-voucher-matching{,.pg}.test.ts these tests
* focus on the dispatcher/executor wiring + status mapping.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createQueuedMockSupabase, makeSupplierInvoice } from '@/tests/helpers'
import type { PendingOperation } from '@/types'
import { commitPendingOperation } from '../commit'
const SI_UUID = '550e8400-e29b-41d4-a716-446655440010'
const JE_UUID = '550e8400-e29b-41d4-a716-446655440011'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'link_supplier_invoice_voucher',
status: 'pending',
title: 'test',
params: {},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-06-01T00:00:00Z',
resolved_at: null,
updated_at: '2026-06-01T00:00:00Z',
...overrides,
} as PendingOperation
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: link_supplier_invoice_voucher', () => {
it('returns 400 when supplier_invoice_id is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's reject update
const op = makePendingOp({ params: { journal_entry_id: JE_UUID } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/supplier_invoice_id/i)
})
it('happy path: links the verifikat and marks the supplier invoice paid', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
vi.spyOn(eventBus, 'emit').mockResolvedValue(undefined)
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
// executor -> linkSupplierInvoiceToVoucher -> RPC
enqueue({
data: {
ok: true,
payment_id: 'sip-1',
invoice_status: 'paid',
paid_amount: 1000,
remaining_amount: 0,
payment_amount: 1000,
journal_entry_id: JE_UUID,
currency: 'SEK',
},
error: null,
})
// post-link invoice re-fetch for the event payload
enqueue({
data: makeSupplierInvoice({ id: SI_UUID, status: 'paid', total: 1000, remaining_amount: 0 }),
error: null,
})
enqueue({ data: null, error: null }) // dispatcher commit update
const op = makePendingOp({ params: { supplier_invoice_id: SI_UUID, journal_entry_id: JE_UUID } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
invoice_status: 'paid',
paid_amount: 1000,
remaining_amount: 0,
payment_amount: 1000,
payment_id: 'sip-1',
journal_entry_id: JE_UUID,
})
})
it('auto-rejects with 404 when the RPC reports the invoice is gone', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { ok: false, code: 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND' }, error: null }) // RPC
enqueue({ data: null, error: null }) // dispatcher's auto-reject update
const op = makePendingOp({ params: { supplier_invoice_id: SI_UUID, journal_entry_id: JE_UUID } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(404)
})
it('auto-rejects with 409 when the verifikat is already linked', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { ok: false, code: 'LINK_SI_VOUCHER_ALREADY_LINKED' }, error: null }) // RPC
enqueue({ data: null, error: null }) // dispatcher's auto-reject update
const op = makePendingOp({ params: { supplier_invoice_id: SI_UUID, journal_entry_id: JE_UUID } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
})
+51
View File
@@ -40,6 +40,7 @@ import {
createSupplierInvoiceRegistrationEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching'
import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { parseSIEFile } from '@/lib/import/sie-parser'
@@ -1038,6 +1039,48 @@ async function commitLinkInvoiceVoucher(
}
}
async function commitLinkSupplierInvoiceVoucher(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const supplierInvoiceId = params.supplier_invoice_id as string | undefined
const journalEntryId = params.journal_entry_id as string | undefined
const notes = (params.notes as string | undefined) ?? undefined
if (!supplierInvoiceId || !journalEntryId) {
return { error: 'supplier_invoice_id and journal_entry_id are required', status: 400 }
}
const outcome = await linkSupplierInvoiceToVoucher(supabase, userId, companyId, {
supplierInvoiceId,
journalEntryId,
notes,
})
if (!outcome.ok) {
const entry = getErrorEntry(outcome.code)
// 404/409 are auto-rejected by the dispatcher (the user can re-stage with
// adjusted inputs); 400 surfaces as a normal failure so the UI can explain.
return {
error: entry?.message_en ?? outcome.code,
status: entry?.httpStatus ?? 500,
}
}
return {
data: {
invoice_status: outcome.result.invoiceStatus,
paid_amount: outcome.result.paidAmount,
remaining_amount: outcome.result.remainingAmount,
payment_amount: outcome.result.paymentAmount,
payment_id: outcome.result.paymentId,
journal_entry_id: outcome.result.journalEntryId,
},
}
}
// ── Stream 1 Phase 1 + follow-up executors ───────────────────────
async function commitClosePeriod(
@@ -1614,6 +1657,11 @@ async function commitCreateSupplierInvoiceFromInbox(
vat_code: null,
vat_rate: vatRate,
vat_amount: vatAmt,
// For reverse charge the buyer self-assesses VAT; carry an explicit
// statutory rate when staged, else null (engine defaults to 25%).
reverse_charge_rate: reverseCharge
? ([0.06, 0.12, 0.25].includes(Number(item.reverse_charge_rate)) ? Number(item.reverse_charge_rate) : null)
: null,
}
})
@@ -2909,6 +2957,9 @@ export async function commitPendingOperation(
case 'link_invoice_voucher':
result = await commitLinkInvoiceVoucher(supabase, userId, companyId, pendingOp.params)
break
case 'link_supplier_invoice_voucher':
result = await commitLinkSupplierInvoiceVoucher(supabase, userId, companyId, pendingOp.params)
break
case 'close_period':
result = await commitClosePeriod(supabase, userId, companyId, pendingOp.params)
break
+5
View File
@@ -30,6 +30,11 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// entry is created or modified. Sits next to match_transaction_invoice
// semantically — both attach an existing booking to an invoice.
link_invoice_voucher: 'medium',
// Supplier-side mirror of link_invoice_voucher: link an existing posted
// verifikat (Dr 2440) as payment for a leverantörsfaktura. Reversible by
// deleting the supplier_invoice_payments row and reverting status; no journal
// entry is created or modified.
link_supplier_invoice_voucher: 'medium',
create_invoice: 'medium', // creates as draft; sending is a separate op
create_transaction: 'medium', // ingests an uncategorized row; reversible by delete
// Supplier master data carries payment-routing fields (IBAN, BIC, bankgiro,
@@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest'
import { mapFortnoxToSupplierInvoice, mapFortnoxToSalesInvoice } from '../mapper'
/**
* Guards the paid-status hardening: deriveInvoiceStatus and paymentStatus.paid
* share one isFullyPaid() source of truth, so status === 'paid' iff
* paymentStatus.paid (for non-cancelled / non-credit rows). An ABSENT Balance
* must never be read as paid on either the supplier OR the sales path.
*/
function supplierRaw(over: Record<string, unknown>): Record<string, unknown> {
return {
GivenNumber: '100',
Total: 1000,
InvoiceDate: '2026-01-10',
DueDate: '2026-02-10',
SupplierName: 'Leverantör AB',
Booked: true,
...over,
}
}
function salesRaw(over: Record<string, unknown>): Record<string, unknown> {
return {
DocumentNumber: '200',
Total: 1000,
InvoiceDate: '2026-01-10',
DueDate: '2026-02-10',
CustomerName: 'Kund AB',
Sent: true,
...over,
}
}
describe('Fortnox mapper — paid-status consistency', () => {
it('supplier: absent Balance is NOT paid (defaults to unpaid, not 0)', () => {
const dto = mapFortnoxToSupplierInvoice(supplierRaw({})) // no Balance key
expect(dto.status).toBe('booked')
expect(dto.paymentStatus.paid).toBe(false)
expect(dto.paymentStatus.balance.value).toBe(1000)
})
it('supplier: Balance 0 → paid and status paid', () => {
const dto = mapFortnoxToSupplierInvoice(supplierRaw({ Balance: 0 }))
expect(dto.status).toBe('paid')
expect(dto.paymentStatus.paid).toBe(true)
})
it('supplier: positive Balance → unpaid', () => {
const dto = mapFortnoxToSupplierInvoice(supplierRaw({ Balance: 250 }))
expect(dto.status).toBe('booked')
expect(dto.paymentStatus.paid).toBe(false)
expect(dto.paymentStatus.balance.value).toBe(250)
})
it('supplier: FullyPaid flag with absent Balance keeps status and paid CONSISTENT', () => {
// Previously deriveInvoiceStatus said paid while paymentStatus.paid said unpaid.
const dto = mapFortnoxToSupplierInvoice(supplierRaw({ FullyPaid: true }))
expect(dto.status).toBe('paid')
expect(dto.paymentStatus.paid).toBe(true)
// paid ⇒ no outstanding balance, even though the raw payload omits Balance
// (previously balance fell back to the full total, contradicting paid=true).
expect(dto.paymentStatus.balance.value).toBe(0)
})
it('sales: absent Balance is NOT paid (no false-paid on the sales path)', () => {
const dto = mapFortnoxToSalesInvoice(salesRaw({})) // no Balance key
expect(dto.status).toBe('sent')
expect(dto.paymentStatus.paid).toBe(false)
expect(dto.paymentStatus.balance.value).toBe(1000)
})
it('sales: Balance 0 → paid and status paid', () => {
const dto = mapFortnoxToSalesInvoice(salesRaw({ Balance: 0 }))
expect(dto.status).toBe('paid')
expect(dto.paymentStatus.paid).toBe(true)
})
it('sales: FullyPaid flag with absent Balance → paid with zero balance', () => {
const dto = mapFortnoxToSalesInvoice(salesRaw({ FullyPaid: true }))
expect(dto.status).toBe('paid')
expect(dto.paymentStatus.paid).toBe(true)
expect(dto.paymentStatus.balance.value).toBe(0)
})
it('status === paid iff paymentStatus.paid across a matrix (both paths)', () => {
const balances = [undefined, 0, 0.004, 250, 1000]
const flags = [undefined, true]
for (const Balance of balances) {
for (const FullyPaid of flags) {
const over: Record<string, unknown> = { FullyPaid }
if (Balance !== undefined) over.Balance = Balance
for (const dto of [
mapFortnoxToSupplierInvoice(supplierRaw(over)),
mapFortnoxToSalesInvoice(salesRaw(over)),
]) {
expect(
dto.status === 'paid',
`Balance=${Balance} FullyPaid=${FullyPaid}`,
).toBe(dto.paymentStatus.paid)
// Invariant: paid ⇒ balance zeroed (never "fully paid yet full balance").
if (dto.paymentStatus.paid) {
expect(
dto.paymentStatus.balance.value,
`Balance=${Balance} FullyPaid=${FullyPaid}`,
).toBe(0)
}
}
}
}
})
})
+32 -5
View File
@@ -14,10 +14,22 @@ function amount(value: number | undefined | null, currency: string = 'SEK'): Amo
return { value: value ?? 0, currencyCode: currency };
}
/**
* Single source of truth for "is this invoice fully settled?", used by BOTH
* deriveInvoiceStatus and the paymentStatus.paid flag so they can never diverge.
* Numeric, not strict === 0, so a residual öre / float drift still reads as paid.
* Number(undefined ?? NaN) = NaN and NaN <= 0 is false, so an ABSENT Balance is
* treated as NOT paid (the supplier-invoice list payload omits Balance) only an
* explicit FullyPaid flag or a present non-positive Balance counts as paid.
*/
function isFullyPaid(raw: Record<string, unknown>): boolean {
return raw['FullyPaid'] === true || Number(raw['Balance'] ?? NaN) <= 0;
}
function deriveInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
if (raw['Cancelled'] === true) return 'cancelled';
if (raw['Credit'] === true) return 'credited';
if (raw['FullyPaid'] === true || raw['Balance'] === 0) return 'paid';
if (isFullyPaid(raw)) return 'paid';
if (raw['Booked'] === true) return 'booked';
if (raw['Sent'] === true) return 'sent';
return 'draft';
@@ -49,7 +61,14 @@ function buildParty(name: string, orgNumber?: string, address?: Record<string, u
export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
const currency = (raw['Currency'] as string) ?? 'SEK';
const total = raw['Total'] as number ?? 0;
const balance = raw['Balance'] as number ?? 0;
// Default an ABSENT Balance to the full total (= fully unpaid), never 0, so a
// missing Balance never silently reads as paid. A present Balance (incl. 0) is
// used as-is. Mirrors the supplier path; paid-ness comes from isFullyPaid().
// When paid, force balance to 0 so the DTO is internally consistent
// (paid ⇒ nothing outstanding): an explicit FullyPaid with no Balance field
// would otherwise leave balance = total alongside paid = true.
const paid = isFullyPaid(raw);
const balance = paid ? 0 : ((raw['Balance'] as number | undefined) ?? total);
const rows = (raw['InvoiceRows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
@@ -72,7 +91,7 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
};
const paymentStatus: PaymentStatusDto = {
paid: balance === 0 && total > 0,
paid,
balance: amount(balance, currency),
};
@@ -107,7 +126,15 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
export function mapFortnoxToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
const currency = (raw['Currency'] as string) ?? 'SEK';
const total = raw['Total'] as number ?? 0;
const balance = raw['Balance'] as number ?? 0;
// Default an ABSENT Balance to the full total (= fully unpaid), never 0.
// The supplier-invoice list is fetched with ?filter=unpaid, so a missing
// Balance must not be mistaken for "settled" — that would flip a genuinely
// open payable to paid downstream. A present Balance (incl. 0) is used as-is.
// When paid, force balance to 0 so the DTO is internally consistent
// (paid ⇒ nothing outstanding): an explicit FullyPaid with no Balance field
// would otherwise leave balance = total alongside paid = true.
const paid = isFullyPaid(raw);
const balance = paid ? 0 : ((raw['Balance'] as number | undefined) ?? total);
const rows = (raw['SupplierInvoiceRows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({
@@ -127,7 +154,7 @@ export function mapFortnoxToSupplierInvoice(raw: Record<string, unknown>): Suppl
};
const paymentStatus: PaymentStatusDto = {
paid: balance === 0 && total > 0,
paid,
balance: amount(balance, currency),
};
@@ -13,6 +13,7 @@ function makeBuilder() {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
@@ -1050,3 +1051,65 @@ describe('calculateVatDeclaration — parent/summary accounts', () => {
expect(result.rutor.ruta49).toBe(2719.55) // 9768 7048.45, owed (was 7048.45 pre-fix)
})
})
describe('calculateVatDeclaration — annual VAT spans the räkenskapsår', () => {
it('uses the fiscal period bounds for yearly when a fiscalPeriodId is given', async () => {
// Förlängt räkenskapsår (extended first year, 18 months) — annual VAT
// (helårsmoms) must cover the whole period, not the calendar year that
// period_start falls in. The first queued result feeds the fiscal_periods
// lookup, the second the journal lines, the third the entry counts.
results = [
{ data: { period_start: '2025-07-03', period_end: '2026-12-31' }, error: null },
{
data: [
{ account_number: '3001', debit_amount: 0, credit_amount: 21600 },
{ account_number: '2610', debit_amount: 0, credit_amount: 9768 },
{ account_number: '2641', debit_amount: 7048.45, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(
supabase, 'company-1', 'yearly', 2026, 1, 'accrual', { fiscalPeriodId: 'fp-1' },
)
expect(result.period.start).toBe('2025-07-03')
expect(result.period.end).toBe('2026-12-31')
expect(result.rutor.ruta05).toBe(21600)
expect(result.rutor.ruta10).toBe(9768)
expect(result.rutor.ruta48).toBe(7048.45)
})
it('falls back to the calendar year when the fiscal period cannot be resolved', async () => {
results = [
{ data: null, error: null }, // fiscal_periods lookup → not found
{ data: [], error: null }, // journal lines
{ data: [], error: null }, // entry counts
]
const result = await calculateVatDeclaration(
supabase, 'company-1', 'yearly', 2026, 1, 'accrual', { fiscalPeriodId: 'missing' },
)
expect(result.period.start).toBe('2026-01-01')
expect(result.period.end).toBe('2026-12-31')
})
it('ignores fiscalPeriodId for monthly periods (calendar month, no lookup)', async () => {
// No fiscal_periods lookup is made for monthly, so the first queued result
// is the journal lines — proving the räkenskapsår path is yearly-only.
results = [
{ data: [], error: null }, // journal lines
{ data: [], error: null }, // entry counts
]
const result = await calculateVatDeclaration(
supabase, 'company-1', 'monthly', 2026, 3, 'accrual', { fiscalPeriodId: 'fp-1' },
)
expect(result.period.start).toBe('2026-03-01')
expect(result.period.end).toBe('2026-03-31')
})
})
+6 -1
View File
@@ -296,12 +296,17 @@ async function generatePeriodReports(
let vatDeclaration: unknown = null
try {
const startDate = new Date(period.period_start)
// Annual VAT for an archive must cover the whole räkenskapsår, which may be
// extended/shortened — pass the fiscal period so the span isn't truncated to
// the calendar year that period_start happens to fall in.
vatDeclaration = await calculateVatDeclaration(
supabase,
companyId,
'yearly',
startDate.getFullYear(),
1
1,
'accrual',
{ fiscalPeriodId: period.id }
)
} catch {
// VAT declaration may fail if no relevant entries exist — skip gracefully
+45 -2
View File
@@ -185,6 +185,44 @@ function round(value: number): number {
return Math.round(value * 100) / 100
}
/**
* Resolve the start/end dates for a VAT period.
*
* Monthly and quarterly VAT periods are always calendar months/quarters
* (kalendermånad / kalenderkvartal per SFL 26 kap), so they use the plain
* calendar calculation.
*
* Annual VAT (helårsmoms), however, is reported per *räkenskapsår* the
* beskattningsår not per calendar year (SFL 26 kap 1011 §§). A räkenskapsår
* can be extended or shortened (up to 18 months for a first/changed year per
* BFL 3 kap 3 §), so a calendar JanDec span would silently drop part of an
* extended year (e.g. a first year 2025-07-03 2026-12-31). When the caller
* supplies the fiscal period we therefore use its actual bounds. If the period
* can't be resolved we fall back to the calendar span so behaviour degrades
* gracefully instead of erroring.
*/
async function resolvePeriodDates(
supabase: SupabaseClient,
companyId: string,
periodType: VatPeriodType,
year: number,
period: number,
fiscalPeriodId?: string
): Promise<{ start: string; end: string }> {
if (periodType === 'yearly' && fiscalPeriodId) {
const { data: fp } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.maybeSingle()
if (fp?.period_start && fp?.period_end) {
return { start: fp.period_start, end: fp.period_end }
}
}
return calculatePeriodDates(periodType, year, period)
}
/**
* Calculate VAT declaration from the general ledger.
*
@@ -203,9 +241,14 @@ export async function calculateVatDeclaration(
periodType: VatPeriodType,
year: number,
period: number,
_accountingMethod: AccountingMethod = 'accrual'
_accountingMethod: AccountingMethod = 'accrual',
options: { fiscalPeriodId?: string } = {}
): Promise<VatDeclaration> {
const { start, end } = calculatePeriodDates(periodType, year, period)
// For yearly VAT this resolves to the räkenskapsår bounds (when a fiscal
// period is supplied), not the calendar year — see resolvePeriodDates.
const { start, end } = await resolvePeriodDates(
supabase, companyId, periodType, year, period, options.fiscalPeriodId
)
// Fetch all posted journal entry lines on VAT-relevant accounts for the period
const lines = await fetchAllRows<{
+4 -3
View File
@@ -2489,9 +2489,8 @@
},
"supplier_invoice_editor": {
"page_title": "Register supplier invoice",
"no_period_warning": "Invoice date {date} falls outside every fiscal year you have set up.",
"no_period_help": "Create the fiscal year so the invoice can be bookedotherwise it can't be registered.",
"create_period": "Create fiscal year",
"no_period_warning": "You're creating a supplier invoice for a fiscal year that doesn't exist ({date}).",
"no_period_help": "Create the fiscal year first, or change the invoice date — the invoice can't be registered without one.",
"back_aria": "Back to supplier invoices",
"back_aria_inbox": "Back to the inbox",
"loading_inbox": "Loading data from inbox…",
@@ -2531,6 +2530,8 @@
"col_amount": "Amount",
"col_vat_rate": "VAT rate",
"col_vat": "VAT",
"col_rc_vat_rate": "VAT rate (RC)",
"col_rc_vat": "Self-assessed VAT",
"col_debit": "Debit",
"col_credit": "Credit",
"vat_rate_presets_aria": "Pick VAT rate from list",
+4 -3
View File
@@ -2489,9 +2489,8 @@
},
"supplier_invoice_editor": {
"page_title": "Registrera leverantörsfaktura",
"no_period_warning": "Fakturadatumet {date} ligger utanför alla upplagda räkenskapsår.",
"no_period_help": "Lägg upp räkenskapsåret så att fakturan kan bokföras annars kan den inte registreras.",
"create_period": "Skapa räkenskapsår",
"no_period_warning": "Du skapar en leverantörsfaktura för ett räkenskapsår som inte finns ({date}).",
"no_period_help": "Lägg upp räkenskapsåret först, eller ändra fakturadatumet fakturan kan inte registreras utan ett räkenskapsår.",
"back_aria": "Tillbaka till leverantörsfakturor",
"back_aria_inbox": "Tillbaka till inkorgen",
"loading_inbox": "Laddar uppgifter från inkorgen…",
@@ -2531,6 +2530,8 @@
"col_amount": "Belopp",
"col_vat_rate": "Momssats",
"col_vat": "Moms",
"col_rc_vat_rate": "Momssats (omvänd)",
"col_rc_vat": "Beräknad moms",
"col_debit": "Debet",
"col_credit": "Kredit",
"vat_rate_presets_aria": "Välj momssats från lista",
@@ -0,0 +1,61 @@
-- Backfill `link_supplier_invoice_voucher` into the
-- pending_operations.operation_type CHECK constraint.
--
-- The supplier-side mirror of `link_invoice_voucher` (added in
-- 20260528120001). The new MCP tool gnubok_link_supplier_invoice_to_voucher
-- stages a `link_supplier_invoice_voucher` pending operation, which is then
-- committed by commitLinkSupplierInvoiceVoucher via the
-- link_supplier_invoice_to_voucher RPC (20260529130000 / 20260529140000).
-- The op also has a risk-tier entry ('medium', reversible — no journal entry
-- is created or modified).
--
-- Without this migration any INSERT staged by the new tool would be rejected
-- with a constraint violation, silently blocking the supplier_invoice_payments
-- audit-trail row required by BFL 5 kap 67§ (every affärshändelse must have a
-- verifikation with a logged payment match).
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_operation_type_check
CHECK (operation_type IN (
'categorize_transaction',
'create_customer',
'create_invoice',
'mark_invoice_paid',
'send_invoice',
'mark_invoice_sent',
'match_transaction_invoice',
'close_period',
'lock_period',
'unlock_period',
'set_opening_balances',
'run_year_end',
'run_currency_revaluation',
'import_sie',
'explain_voucher_gap',
'uncategorize_transaction',
'approve_supplier_invoice',
'credit_supplier_invoice',
'credit_invoice',
'convert_invoice',
'create_transaction',
'attach_document_to_transaction',
'create_voucher',
'correct_entry',
'reverse_entry',
'create_supplier',
'create_supplier_invoice_from_inbox',
'post_annual_depreciation',
'link_invoice_voucher',
'undo_sie_import',
'match_batch_allocate',
'bulk_book_transactions',
'create_salary_run',
'generate_agi',
'link_transaction_journal_entry',
'link_supplier_invoice_voucher' -- supplier-side mirror of link_invoice_voucher
));
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,26 @@
-- Add reverse_charge_rate to supplier_invoice_items.
--
-- For omvänd skattskyldighet (reverse charge, ML 16 kap) the EU/non-EU or
-- domestic-RC supplier charges no VAT, so the line's own vat_rate is 0 — the
-- v1 supplier-invoice API even mandates vat_rate=0 on every RC line. The buyer
-- must nonetheless self-assess BOTH output and input VAT at the Swedish
-- statutory rate that would apply to the service domestically: 25% under
-- huvudregeln for EU services (ML 6 kap 34 §), or 12%/6% for reduced-rated
-- services. That self-assessed rate is conceptually distinct from "what the
-- supplier charged" (0%), so it gets its own column instead of overloading
-- vat_rate — which previously caused the engine to skip the fiktiv-moms lines
-- (2614/2624/2634 + 2645/2647) and basbeloppsrader (44xx/45xx) entirely,
-- understating momsdeklaration ruta 20-24 / 30-32 / 48.
--
-- NULL = not a reverse-charge line (the booking engine then falls back to a
-- positive vat_rate if present, else the 25% huvudregel default).
-- 0.06 / 0.12 / 0.25 = explicit self-assessed rate (set by the UI picker).
ALTER TABLE supplier_invoice_items
ADD COLUMN IF NOT EXISTS reverse_charge_rate numeric
CHECK (reverse_charge_rate IS NULL OR reverse_charge_rate IN (0.06, 0.12, 0.25));
COMMENT ON COLUMN supplier_invoice_items.reverse_charge_rate IS
'Self-assessed VAT rate for omvänd skattskyldighet (decimal 0.06/0.12/0.25). NULL for non-RC lines. The line vat_rate stays 0 (supplier charges no VAT); this rate drives fiktiv moms (2614/2624/2634 + 2645/2647) and basbelopp (44xx/45xx) booking — see lib/bookkeeping/supplier-invoice-entries.ts.';
NOTIFY pgrst, 'reload schema';
+10
View File
@@ -657,6 +657,10 @@ export interface SupplierInvoiceItem {
vat_code: string | null
vat_rate: number
vat_amount: number
// Self-assessed VAT rate for omvänd skattskyldighet (0.06/0.12/0.25), null
// for non-RC lines. The supplier charges no VAT so vat_rate stays 0; this
// rate drives the fiktiv-moms + basbelopp booking. See the booking engine.
reverse_charge_rate: number | null
created_at: string
}
@@ -965,6 +969,9 @@ export interface CreateSupplierInvoiceItemInput {
vat_rate?: number
// Manual override. See CreateSupplierInvoiceItemSchema for rationale.
vat_amount?: number
// Self-assessed VAT rate for omvänd skattskyldighet (0.06/0.12/0.25). When
// set, the engine books fiktiv moms at this rate while vat_rate stays 0.
reverse_charge_rate?: number
vat_code?: string
// Legacy fields (backward compat, ignored when amount is set)
quantity?: number
@@ -1573,6 +1580,9 @@ export type PendingOperationType =
| 'generate_agi'
// Mark invoice paid by linking an existing posted verifikat (no new JE)
| 'link_invoice_voucher'
// Supplier-side mirror: mark a leverantörsfaktura paid by linking an existing
// posted verifikat that debits 2440 (no new JE)
| 'link_supplier_invoice_voucher'
// PR #603/#607: allocate 1 bank tx across N customer or supplier invoices
| 'match_batch_allocate'
// PR #606/#610: bulk-book N bank txs into 1 combined verifikat