+
+
+
{t('no_period_warning', { date: watchedInvoiceDate })}
{t('no_period_help')}
-
setShowCreatePeriod(true)}
- className="shrink-0"
- >
-
- {t('create_period')}
-
)}
@@ -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 })
}
>
@@ -1282,8 +1316,8 @@ export default function NewSupplierInvoicePage() {
{t('col_account')}
{t('col_description')}
{t('col_amount_excl')}
-
{t('col_vat_rate')}
-
{t('col_vat')}
+
{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}
+
{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}
@@ -1336,13 +1370,23 @@ export default function NewSupplierInvoicePage() {
/>
- (
-
- )}
- />
+ {watchedReverseCharge ? (
+ (
+
+ )}
+ />
+ ) : (
+ (
+
+ )}
+ />
+ )}
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
@@ -1418,18 +1462,28 @@ export default function NewSupplierInvoicePage() {
/>
- {t('col_vat_rate')}
- (
-
- )}
- />
+ {watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}
+ {watchedReverseCharge ? (
+ (
+
+ )}
+ />
+ ) : (
+ (
+
+ )}
+ />
+ )}
- {t('col_vat')}
+ {watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
@@ -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() {
)}
{ submitModeRef.current = 'register' }}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
@@ -1733,14 +1787,6 @@ export default function NewSupplierInvoicePage() {
-
-
)
}
diff --git a/app/api/reports/vat-declaration/route.ts b/app/api/reports/vat-declaration/route.ts
index 97ed1161..c5172236 100644
--- a/app/api/reports/vat-declaration/route.ts
+++ b/app/api/reports/vat-declaration/route.ts
@@ -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),
},
})
diff --git a/app/api/reports/vat-declaration/xlsx/route.ts b/app/api/reports/vat-declaration/xlsx/route.ts
index 3910c94e..d015604a 100644
--- a/app/api/reports/vat-declaration/xlsx/route.ts
+++ b/app/api/reports/vat-declaration/xlsx/route.ts
@@ -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(
diff --git a/app/api/supplier-invoices/[id]/credit/route.ts b/app/api/supplier-invoices/[id]/credit/route.ts
index b5134302..0685c8f0 100644
--- a/app/api/supplier-invoices/[id]/credit/route.ts
+++ b/app/api/supplier-invoices/[id]/credit/route.ts
@@ -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)
diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts
index 83f5b79e..dc593816 100644
--- a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts
+++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts
@@ -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',
},
],
diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts
index c55bc149..d177d95d 100644
--- a/app/api/supplier-invoices/route.ts
+++ b/app/api/supplier-invoices/route.ts
@@ -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,
}
})
diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts
index 9a2972c6..fbc3d328 100644
--- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts
+++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts
@@ -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
@@ -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
diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts
index efc3b489..6deee803 100644
--- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts
+++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts
@@ -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)
diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts
index 77b65c3e..04be8e51 100644
--- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts
+++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts
@@ -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'
diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts
index 4fb9fd35..d29adaf5 100644
--- a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts
+++ b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts
@@ -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 sum + i.line_total, 0)
diff --git a/components/suppliers/SupplierInvoiceReviewContent.tsx b/components/suppliers/SupplierInvoiceReviewContent.tsx
index 9c52bde3..61bc7032 100644
--- a/components/suppliers/SupplierInvoiceReviewContent.tsx
+++ b/components/suppliers/SupplierInvoiceReviewContent.tsx
@@ -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()
+ const nonBasisBaseByRate = new Map()
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({
{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 (
@@ -258,7 +302,7 @@ export function SupplierInvoiceReviewContent({
{item.description}
{formatAmount(item.amount)}
- {Math.round(item.vat_rate * 100)}%
+ {Math.round(displayRate * 100)}%
{formatAmount(vatAmount)}
)
@@ -268,9 +312,12 @@ export function SupplierInvoiceReviewContent({
{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 (
@@ -279,7 +326,7 @@ export function SupplierInvoiceReviewContent({
{formatAmount(item.amount)} kr
- {t('review_vat_inline', { rate: Math.round(item.vat_rate * 100), amount: formatAmount(vatAmount) })}
+ {t('review_vat_inline', { rate: Math.round(displayRate * 100), amount: formatAmount(vatAmount) })}
)
@@ -293,7 +340,7 @@ export function SupplierInvoiceReviewContent({
{formatCurrency(subtotal, currency)}
- {t('vat_label_short')}
+ {reverseCharge ? t('vat_reverse_charge') : t('vat_label_short')}
{formatCurrency(totalVat, currency)}
diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts
index 049b4646..c7966af3 100644
--- a/extensions/general/arcim-migration/index.ts
+++ b/extensions/general/arcim-migration/index.ts
@@ -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',
diff --git a/extensions/general/arcim-migration/lib/__tests__/entity-mapper-status.test.ts b/extensions/general/arcim-migration/lib/__tests__/entity-mapper-status.test.ts
new file mode 100644
index 00000000..7aa1c46d
--- /dev/null
+++ b/extensions/general/arcim-migration/lib/__tests__/entity-mapper-status.test.ts
@@ -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[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)
+ }
+ }
+ }
+ })
+})
diff --git a/extensions/general/arcim-migration/lib/entity-mapper.ts b/extensions/general/arcim-migration/lib/entity-mapper.ts
index 78e1b81a..808f1e75 100644
--- a/extensions/general/arcim-migration/lib/entity-mapper.ts
+++ b/extensions/general/arcim-migration/lib/entity-mapper.ts
@@ -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 = {
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,
}
diff --git a/extensions/general/arcim-migration/lib/migration-orchestrator.ts b/extensions/general/arcim-migration/lib/migration-orchestrator.ts
index 71aa5a36..2c57d44d 100644
--- a/extensions/general/arcim-migration/lib/migration-orchestrator.ts
+++ b/extensions/general/arcim-migration/lib/migration-orchestrator.ts
@@ -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 {
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
diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts
index 388011c8..af479472 100644
--- a/extensions/general/enable-banking/lib/sync.ts
+++ b/extensions/general/enable-banking/lib/sync.ts
@@ -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,
diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts
index d32a6e0d..f11b9496 100644
--- a/extensions/general/invoice-inbox/index.ts
+++ b/extensions/general/invoice-inbox/index.ts
@@ -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',
+ },
+ })
}
}
diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts
index 50bd1d1f..b0b86ba9 100644
--- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts
+++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts
@@ -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)
})
})
diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts
index cb4f9445..568a0ef8 100644
--- a/extensions/general/mcp-server/server.ts
+++ b/extensions/general/mcp-server/server.ts
@@ -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.",
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index 6d5bcbf4..a7e63eae 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -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(),
diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts
index 9a817522..290e402d 100644
--- a/lib/auth/api-keys.ts
+++ b/lib/auth/api-keys.ts
@@ -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 = {
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',
diff --git a/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts
index d2808047..d3697777 100644
--- a/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts
+++ b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts
@@ -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()
+ 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 = {}): 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,
diff --git a/lib/bookkeeping/supplier-invoice-entries.ts b/lib/bookkeeping/supplier-invoice-entries.ts
index f747b9e5..3aba7f40 100644
--- a/lib/bookkeeping/supplier-invoice-entries.ts
+++ b/lib/bookkeeping/supplier-invoice-entries.ts
@@ -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 {
const baseByRate = new Map()
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 {
const baseByRate = new Map()
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)
diff --git a/lib/bookkeeping/vat-entries.ts b/lib/bookkeeping/vat-entries.ts
index 47d23aba..57e05050 100644
--- a/lib/bookkeeping/vat-entries.ts
+++ b/lib/bookkeeping/vat-entries.ts
@@ -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 = 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]
diff --git a/lib/invoices/__tests__/bulk-reconcile-supplier-vouchers.test.ts b/lib/invoices/__tests__/bulk-reconcile-supplier-vouchers.test.ts
new file mode 100644
index 00000000..a70ae944
--- /dev/null
+++ b/lib/invoices/__tests__/bulk-reconcile-supplier-vouchers.test.ts
@@ -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')
+ })
+})
diff --git a/lib/invoices/bulk-reconcile-supplier-vouchers.ts b/lib/invoices/bulk-reconcile-supplier-vouchers.ts
new file mode 100644
index 00000000..d40ee053
--- /dev/null
+++ b/lib/invoices/bulk-reconcile-supplier-vouchers.ts
@@ -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 {
+ 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(
+ ({ 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()
+ 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
+}
diff --git a/lib/pending-operations/__tests__/link-supplier-invoice-voucher.test.ts b/lib/pending-operations/__tests__/link-supplier-invoice-voucher.test.ts
new file mode 100644
index 00000000..0364193e
--- /dev/null
+++ b/lib/pending-operations/__tests__/link-supplier-invoice-voucher.test.ts
@@ -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 {
+ 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)
+ })
+})
diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts
index ed7c67a7..b8eecfee 100644
--- a/lib/pending-operations/commit.ts
+++ b/lib/pending-operations/commit.ts
@@ -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
+): Promise {
+ 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
diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts
index bffa6a08..97fdd182 100644
--- a/lib/pending-operations/risk-tiers.ts
+++ b/lib/pending-operations/risk-tiers.ts
@@ -30,6 +30,11 @@ export const OPERATION_RISK_TIERS: Record = {
// 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,
diff --git a/lib/providers/fortnox/__tests__/mapper-payment-status.test.ts b/lib/providers/fortnox/__tests__/mapper-payment-status.test.ts
new file mode 100644
index 00000000..bf4a4910
--- /dev/null
+++ b/lib/providers/fortnox/__tests__/mapper-payment-status.test.ts
@@ -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): Record {
+ return {
+ GivenNumber: '100',
+ Total: 1000,
+ InvoiceDate: '2026-01-10',
+ DueDate: '2026-02-10',
+ SupplierName: 'Leverantör AB',
+ Booked: true,
+ ...over,
+ }
+}
+
+function salesRaw(over: Record): Record {
+ 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 = { 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)
+ }
+ }
+ }
+ }
+ })
+})
diff --git a/lib/providers/fortnox/mapper.ts b/lib/providers/fortnox/mapper.ts
index 73ab21a3..95a061b6 100644
--- a/lib/providers/fortnox/mapper.ts
+++ b/lib/providers/fortnox/mapper.ts
@@ -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): boolean {
+ return raw['FullyPaid'] === true || Number(raw['Balance'] ?? NaN) <= 0;
+}
+
function deriveInvoiceStatus(raw: Record): 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): 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[] | undefined) ?? [];
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
@@ -72,7 +91,7 @@ export function mapFortnoxToSalesInvoice(raw: Record): SalesInv
};
const paymentStatus: PaymentStatusDto = {
- paid: balance === 0 && total > 0,
+ paid,
balance: amount(balance, currency),
};
@@ -107,7 +126,15 @@ export function mapFortnoxToSalesInvoice(raw: Record): SalesInv
export function mapFortnoxToSupplierInvoice(raw: Record): 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[] | undefined) ?? [];
const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({
@@ -127,7 +154,7 @@ export function mapFortnoxToSupplierInvoice(raw: Record): Suppl
};
const paymentStatus: PaymentStatusDto = {
- paid: balance === 0 && total > 0,
+ paid,
balance: amount(balance, currency),
};
diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts
index fb3b4560..cf614b36 100644
--- a/lib/reports/__tests__/vat-declaration.test.ts
+++ b/lib/reports/__tests__/vat-declaration.test.ts
@@ -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')
+ })
+})
diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts
index 66321640..99e3e241 100644
--- a/lib/reports/full-archive-export.ts
+++ b/lib/reports/full-archive-export.ts
@@ -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
diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts
index c9a17a65..19d76654 100644
--- a/lib/reports/vat-declaration.ts
+++ b/lib/reports/vat-declaration.ts
@@ -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 10–11 §§). 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 Jan–Dec 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 {
- 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<{
diff --git a/messages/en.json b/messages/en.json
index 67342160..8a9df048 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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 booked — otherwise 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",
diff --git a/messages/sv.json b/messages/sv.json
index 140a8c22..bbfca033 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -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",
diff --git a/supabase/migrations/20260612120000_pending_operations_add_link_supplier_invoice_voucher.sql b/supabase/migrations/20260612120000_pending_operations_add_link_supplier_invoice_voucher.sql
new file mode 100644
index 00000000..adc65c13
--- /dev/null
+++ b/supabase/migrations/20260612120000_pending_operations_add_link_supplier_invoice_voucher.sql
@@ -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 6–7§ (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';
diff --git a/supabase/migrations/20260612121000_supplier_invoice_items_add_reverse_charge_rate.sql b/supabase/migrations/20260612121000_supplier_invoice_items_add_reverse_charge_rate.sql
new file mode 100644
index 00000000..635d9896
--- /dev/null
+++ b/supabase/migrations/20260612121000_supplier_invoice_items_add_reverse_charge_rate.sql
@@ -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';
diff --git a/types/index.ts b/types/index.ts
index be3f7227..2f96f911 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -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