Bug/customer invoice bug (#628)

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore(gitignore): ignore local SIE test fixtures

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-02 13:25:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 358c25094d
commit f6ee0c2a82
43 changed files with 2226 additions and 182 deletions
+10
View File
@@ -355,6 +355,16 @@ export const CreateSupplierInvoiceItemSchema = z.object({
// currency rounding, or POS receipts where supplier-side rounding makes the
// VAT off by öre.
vat_amount: z.number().min(0).optional(),
// Self-assessed VAT rate for omvänd skattskyldighet (reverse charge). The
// supplier charges no VAT (vat_rate stays 0); this is the Swedish statutory
// rate the buyer self-assesses at — 25% huvudregel default, 12%/6% for
// reduced-rated services (ML 6 kap 34 §). Must be a statutory rate.
reverse_charge_rate: z
.number()
.refine((r) => r === 0.06 || r === 0.12 || r === 0.25, {
message: 'reverse_charge_rate must be 0.06, 0.12, or 0.25',
})
.optional(),
vat_code: z.string().optional(),
quantity: z.number().optional(),
unit: z.string().optional(),
+5 -2
View File
@@ -13,8 +13,8 @@ export const API_KEY_SCOPES = {
'customers:write': { label: 'Kunder — skriv', description: 'Skapa kunder (1 verktyg)' },
'invoices:read': { label: 'Fakturor — läs', description: 'Lista fakturor (1 verktyg)' },
'invoices:write': { label: 'Fakturor — skriv', description: 'Skapa, skicka, markera betald/skickad (4 verktyg)' },
'suppliers:read': { label: 'Leverantörer — läs', description: 'Lista leverantörer och leverantörsfakturor (2 verktyg)' },
'suppliers:write': { label: 'Leverantörer — skriv', description: 'Godkänn och kreditera leverantörsfakturor (2 verktyg)' },
'suppliers:read': { label: 'Leverantörer — läs', description: 'Lista leverantörer och leverantörsfakturor, hitta verifikat-kandidater (3 verktyg)' },
'suppliers:write': { label: 'Leverantörer — skriv', description: 'Skapa leverantörer; godkänn, kreditera, betal-länka och hantera leverantörsfakturor (6 verktyg)' },
'reports:read': { label: 'Rapporter — läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export (12 verktyg)' },
'bookkeeping:write': { label: 'Bokföring — skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar' },
'payroll:read': { label: 'Löner — läs', description: 'Lista anställda, lönekörningar, lönejournal (3 verktyg)' },
@@ -212,6 +212,9 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_credit_supplier_invoice: 'suppliers:write',
gnubok_create_supplier_invoice_from_inbox: 'suppliers:write',
gnubok_set_inbox_extracted_data: 'suppliers:write',
// Supplier invoice payment via existing verifikat (no new bokföring)
gnubok_find_voucher_candidates_for_supplier_invoice: 'suppliers:read',
gnubok_link_supplier_invoice_to_voucher: 'suppliers:write',
// Invoice conversion + crediting
gnubok_convert_invoice: 'invoices:write',
gnubok_credit_invoice: 'invoices:write',
@@ -36,9 +36,14 @@ vi.mock('../currency-utils', () => ({
),
}))
// Mock vat-entries with real reverse charge logic
vi.mock('../vat-entries', () => ({
generateReverseChargeLines: vi.fn().mockImplementation(
// Mock vat-entries: keep the real pure helpers (resolveReverseChargeRate,
// isReverseChargeBasisAccount, RC_BASIS_ACCOUNTS) and stub only the two
// line-builders with simplified logic the assertions below rely on.
vi.mock('../vat-entries', async (importOriginal) => {
const actual = await importOriginal<typeof import('../vat-entries')>()
return {
...actual,
generateReverseChargeLines: vi.fn().mockImplementation(
(baseAmount: number, vatRate: number = 0.25, isDomestic: boolean = false) => {
const vatAmount = Math.round(baseAmount * vatRate * 100) / 100
const inputAccount = isDomestic ? '2647' : '2645'
@@ -72,7 +77,8 @@ vi.mock('../vat-entries', () => ({
]
}
),
}))
}
})
const { createJournalEntry, findFiscalPeriod } = await import('../engine')
const mockedCreateEntry = vi.mocked(createJournalEntry)
@@ -107,6 +113,7 @@ function makeItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoice
vat_code: null,
vat_rate: vatRate,
vat_amount: vatAmount,
reverse_charge_rate: null,
created_at: '2024-06-01T00:00:00Z',
...overrides,
}
@@ -371,6 +378,78 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
assertBalanced(input)
})
it('books reverse charge VAT for a 0%-rate line item — defaults to 25% huvudregeln (regression)', async () => {
// The exact reported bug: a Finnish (EU) supplier invoice entered with the
// line at 0% momssats (the supplier charges no VAT) must still self-assess
// at 25%. Before the fix the `rate > 0` guard skipped ALL VAT lines, so the
// verifikat was just expense + 2440 — the user had to add VAT lines by hand.
const invoice = makeSupplierInvoice({
subtotal: 12000,
vat_amount: 0,
total: 12000,
reverse_charge: true,
})
const items = [makeItem({ line_total: 12000, account_number: '5910', vat_rate: 0, reverse_charge_rate: null })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '5910')[0].debit_amount).toBe(12000)
// Fiktiv moms self-assessed at the 25% huvudregel default (ruta 30 / 48).
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(3000)
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(3000)
// Basbeloppsrader for ruta 21 (EU services) — required or SKV rejects FK004.
expect(findByAccount(input.lines, '4535')[0].debit_amount).toBe(12000)
expect(findByAccount(input.lines, '4598')[0].credit_amount).toBe(12000)
// Leverantörsskuld is the net (no VAT rolls into the payable under RC).
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(12000)
assertBalanced(input)
})
it('honours an explicit reverse_charge_rate (12%) on a 0%-rate line item', async () => {
const invoice = makeSupplierInvoice({
subtotal: 10000, vat_amount: 0, total: 10000, reverse_charge: true,
})
const items = [makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0, reverse_charge_rate: 0.12 })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(1200) // 10000 * 0.12
expect(findByAccount(input.lines, '2624')[0].credit_amount).toBe(1200) // ruta 31
expect(findByAccount(input.lines, '4536')[0].debit_amount).toBe(10000) // ruta 21 @ 12%
// 25% accounts must NOT appear when the self-assessed rate is 12%.
expect(findByAccount(input.lines, '2614')).toHaveLength(0)
expect(findByAccount(input.lines, '4535')).toHaveLength(0)
assertBalanced(input)
})
it('honours an explicit reverse_charge_rate (6%) on a 0%-rate line item', async () => {
const invoice = makeSupplierInvoice({
subtotal: 10000, vat_amount: 0, total: 10000, reverse_charge: true,
})
const items = [makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0, reverse_charge_rate: 0.06 })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(600) // 10000 * 0.06
expect(findByAccount(input.lines, '2634')[0].credit_amount).toBe(600) // ruta 32
expect(findByAccount(input.lines, '4537')[0].debit_amount).toBe(10000) // ruta 21 @ 6%
// Higher-rate accounts must NOT appear when the self-assessed rate is 6%.
expect(findByAccount(input.lines, '2614')).toHaveLength(0)
expect(findByAccount(input.lines, '2624')).toHaveLength(0)
expect(findByAccount(input.lines, '4535')).toHaveLength(0)
expect(findByAccount(input.lines, '4536')).toHaveLength(0)
assertBalanced(input)
})
it('books non-EU services to 4531 (ruta 22) and motkonto 4598', async () => {
const invoice = makeSupplierInvoice({
subtotal: 8000,
@@ -1038,6 +1117,24 @@ describe('createSupplierInvoiceCashEntry', () => {
assertBalanced(input)
})
it('EU reverse charge with a 0%-rate line item self-assesses at 25% (regression)', async () => {
const invoice = makeSupplierInvoice({
subtotal: 12000, vat_amount: 0, total: 12000, reverse_charge: true,
})
const items = [makeItem({ line_total: 12000, account_number: '5910', vat_rate: 0, reverse_charge_rate: null })]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(3000)
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(3000)
expect(findByAccount(input.lines, '4535')[0].debit_amount).toBe(12000)
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(12000)
assertBalanced(input)
})
it('has no 2440 line', async () => {
const invoice = makeSupplierInvoice()
const items = [makeItem()]
@@ -1228,6 +1325,32 @@ describe('createSupplierCreditNoteEntry', () => {
assertBalanced(input)
})
it('reverses a 0%-rate reverse charge credit note at the 25% default (regression)', async () => {
// A credit note for the buggy 0%-rate RC invoice must reverse the same
// self-assessed VAT the registration booked, or it leaves ruta 21/30/48
// half-cancelled. The credit-note path resolves the same 25% default.
const creditNote = makeSupplierInvoice({
is_credit_note: true,
subtotal: -12000,
vat_amount: 0,
total: -12000,
reverse_charge: true,
})
const items = [makeItem({ line_total: -12000, account_number: '5910', vat_rate: 0, reverse_charge_rate: null })]
await createSupplierCreditNoteEntry(
null as never, 'company-1', 'user-1', creditNote, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2645')[0].credit_amount).toBe(3000)
expect(findByAccount(input.lines, '2614')[0].debit_amount).toBe(3000)
expect(findByAccount(input.lines, '4535')[0].credit_amount).toBe(12000)
expect(findByAccount(input.lines, '4598')[0].debit_amount).toBe(12000)
expect(findByAccount(input.lines, '2440')[0].debit_amount).toBe(12000)
assertBalanced(input)
})
it('uses Math.abs for all amounts (negative inputs produce positive lines)', async () => {
const creditNote = makeSupplierInvoice({
is_credit_note: true,
+18 -30
View File
@@ -1,6 +1,11 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { generateReverseChargeLines, generateReverseChargeBasisLines } from './vat-entries'
import {
generateReverseChargeLines,
generateReverseChargeBasisLines,
isReverseChargeBasisAccount,
resolveReverseChargeRate,
} from './vat-entries'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
@@ -13,29 +18,6 @@ import type {
const log = createLogger('supplier-invoice-entries')
/**
* Accounts that already populate momsdeklaration ruta 20-24 directly when
* debited. If the user picked one of these as the expense account on an RC
* invoice item, the engine must NOT add the parallel basbeloppsrader (those
* would double-count the basis).
*/
const RC_BASIS_ACCOUNTS = new Set([
// ruta 20 — EU goods
'4515', '4516', '4517',
// ruta 21 — EU services
'4535', '4536', '4537',
// ruta 22 — non-EU services
'4531', '4532', '4533',
// ruta 23 — domestic goods RC
'4415', '4416', '4417',
// ruta 24 — domestic services RC
'4425', '4426', '4427',
])
function isBasisAccount(account: string): boolean {
return RC_BASIS_ACCOUNTS.has(account)
}
/**
* Build a BFL-compliant verifikation description with event type, counterparty, and suffix.
* Falls back to prefix + invoiceNumber + suffix if name is not provided (backward compat).
@@ -662,9 +644,15 @@ function groupVatByRate(
}
/**
* Group items by VAT rate and sum the base (line_total) per rate.
* Used by reverse-charge paths to compute fiktiv moms from the basis,
* decoupled from any manual VAT override on the items themselves.
* Group items by their self-assessed reverse-charge rate and sum the base
* (line_total) per rate. Used by reverse-charge paths to compute fiktiv moms
* from the basis, decoupled from any manual VAT override on the items.
*
* The grouping key is the *self-assessed* rate (resolveReverseChargeRate), not
* the line's vat_rate: under omvänd skattskyldighet the supplier charges 0%, so
* the line vat_rate is 0, but the buyer self-assesses at 25% (huvudregeln) or
* the explicit per-item reverse_charge_rate. Without this a 0%-rate RC line
* would key on rate 0 and the `rate > 0` guard below would skip its VAT lines.
*/
function groupBaseByRate(
items: SupplierInvoiceItem[],
@@ -674,7 +662,7 @@ function groupBaseByRate(
): Map<number, number> {
const baseByRate = new Map<number, number>()
for (const item of items) {
const rate = item.vat_rate ?? 0.25
const rate = resolveReverseChargeRate(item)
let baseSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
if (useAbsoluteValues) baseSek = Math.abs(baseSek)
baseByRate.set(rate, (baseByRate.get(rate) || 0) + baseSek)
@@ -696,8 +684,8 @@ function groupNonBasisBaseByRate(
): Map<number, number> {
const baseByRate = new Map<number, number>()
for (const item of items) {
if (isBasisAccount(item.account_number)) continue
const rate = item.vat_rate ?? 0.25
if (isReverseChargeBasisAccount(item.account_number)) continue
const rate = resolveReverseChargeRate(item)
let itemSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
if (useAbsoluteValues) itemSek = Math.abs(itemSek)
baseByRate.set(rate, (baseByRate.get(rate) || 0) + itemSek)
+51
View File
@@ -38,6 +38,57 @@ export function getVatRate(treatment: VatTreatment): number {
}
}
/**
* Expense/basis accounts that already populate momsdeklaration ruta 20-24
* directly when debited (the basbelopp for a reverse-charge purchase). If an RC
* item is booked straight to one of these, the engine must NOT add the parallel
* basbeloppsrader — that would double-count ruta 20-24.
*
* ruta 20 EU goods 4515/4516/4517
* ruta 21 EU services 4535/4536/4537
* ruta 22 non-EU services 4531/4532/4533
* ruta 23 domestic goods RC 4415/4416/4417
* ruta 24 domestic services RC 4425/4426/4427
*/
export const RC_BASIS_ACCOUNTS: ReadonlySet<string> = new Set([
'4515', '4516', '4517',
'4535', '4536', '4537',
'4531', '4532', '4533',
'4415', '4416', '4417',
'4425', '4426', '4427',
])
export function isReverseChargeBasisAccount(account: string): boolean {
return RC_BASIS_ACCOUNTS.has(account)
}
/**
* The self-assessed VAT rate to apply to a reverse-charge line.
*
* Under omvänd skattskyldighet the supplier charges no VAT, so the line's own
* `vat_rate` is 0 (the v1 supplier-invoice API mandates this). The buyer must
* still self-assess output + input VAT at the Swedish statutory rate that would
* apply to the service domestically — 25% under huvudregeln for EU services
* (ML 6 kap 34 §), 12%/6% for reduced-rated services. Resolution order:
*
* 1. explicit per-item `reverse_charge_rate` (the UI's self-assessment picker)
* 2. a positive `vat_rate` on the line (legacy/API callers that encoded the
* self-assessment rate directly on vat_rate)
* 3. 25% huvudregel default — never silently drop the fiktiv-moms lines.
*
* Keeping this in one place means the booking engine and the review-dialog
* preview can never drift. The original bug was two independent copies of a
* `rate > 0` assumption, each skipping the VAT entirely on a 0%-rate RC line.
*/
export function resolveReverseChargeRate(
item: { vat_rate?: number | null; reverse_charge_rate?: number | null },
): number {
const explicit = item.reverse_charge_rate
if (explicit != null && explicit > 0) return explicit
if (item.vat_rate != null && item.vat_rate > 0) return item.vat_rate
return 0.25
}
/**
* Generate output VAT lines for sales invoices
* Debit 1510 Kundfordringar [total incl VAT]
@@ -0,0 +1,219 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock the two dependencies so we test the ORCHESTRATION logic (confidence
// gating, near-tie margin, cross-invoice voucher exclusivity, consumed-voucher
// filtering) in isolation. The matcher + RPC link are exercised by their own
// suites (supplier-voucher-matching.test.ts / .pg.test.ts).
vi.mock('@/lib/supabase/fetch-all', () => ({ fetchAllRows: vi.fn() }))
vi.mock('../supplier-voucher-matching', () => ({
findMatchingVouchersForSupplierInvoice: vi.fn(),
linkSupplierInvoiceToVoucher: vi.fn(),
}))
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
findMatchingVouchersForSupplierInvoice,
linkSupplierInvoiceToVoucher,
} from '../supplier-voucher-matching'
import { reconcileSupplierInvoiceVouchers } from '../bulk-reconcile-supplier-vouchers'
const mFetchAll = vi.mocked(fetchAllRows)
const mFind = vi.mocked(findMatchingVouchersForSupplierInvoice)
const mLink = vi.mocked(linkSupplierInvoiceToVoucher)
interface InvOver {
id: string
number?: string
status?: string
total?: number
remaining?: number
due?: string
isCredit?: boolean
}
function inv(over: InvOver) {
const total = over.total ?? 1000
return {
id: over.id,
supplier_invoice_number: over.number ?? `F-${over.id}`,
arrival_number: 1,
status: over.status ?? 'overdue',
currency: 'SEK',
total,
paid_amount: 0,
remaining_amount: over.remaining ?? total,
due_date: over.due ?? '2026-02-01',
paid_at: null,
exchange_rate: null,
supplier_id: 's1',
is_credit_note: over.isCredit ?? false,
supplier: { id: 's1', name: 'Leverantör AB' },
}
}
function cand(over: { je: string; confidence?: number; amount?: number; n?: number }) {
return {
journal_entry_id: over.je,
voucher_series: 'A',
voucher_number: over.n ?? 1,
entry_date: '2026-02-01',
description: 'Leverantörsbetalning',
ap_debit_amount: over.amount ?? 1000,
currency: 'SEK',
ap_line_currency: 'SEK',
period_locked: false,
confidence: over.confidence ?? 0.95,
match_reason: 'test',
}
}
/** Queue the two fetchAllRows reads: invoices, then existing payments. */
function queue(invoices: unknown[], payments: { journal_entry_id: string | null }[] = []) {
mFetchAll.mockReset()
mFetchAll.mockResolvedValueOnce(invoices as never).mockResolvedValueOnce(payments as never)
}
const okLink = (over: { paymentAmount?: number; status?: 'paid' | 'partially_paid'; je: string }) => ({
ok: true as const,
result: {
paymentId: 'p1',
invoiceStatus: over.status ?? ('paid' as const),
paidAmount: 1000,
remainingAmount: 0,
paymentAmount: over.paymentAmount ?? 1000,
journalEntryId: over.je,
},
})
const run = () =>
reconcileSupplierInvoiceVouchers({ supabase: {} as never, companyId: 'c1', userId: 'u1' })
describe('reconcileSupplierInvoiceVouchers', () => {
beforeEach(() => {
vi.clearAllMocks()
mFind.mockReset()
mLink.mockReset()
})
it('auto-links a single unambiguous exact match and marks it paid', async () => {
queue([inv({ id: 'i1', remaining: 1000 })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
mLink.mockResolvedValueOnce(okLink({ je: 'v1' }) as never)
const res = await run()
expect(res.scanned).toBe(1)
expect(res.autoLinked).toBe(1)
expect(res.ambiguous).toBe(0)
expect(res.unmatched).toBe(0)
expect(res.links).toHaveLength(1)
expect(res.links[0]).toMatchObject({ journal_entry_id: 'v1', invoice_status: 'paid' })
expect(mLink).toHaveBeenCalledTimes(1)
expect(mLink).toHaveBeenCalledWith({} , 'u1', 'c1', expect.objectContaining({
supplierInvoiceId: 'i1',
journalEntryId: 'v1',
}))
})
it('does not auto-link a below-threshold (amount-only) candidate', async () => {
queue([inv({ id: 'i1' })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.8, amount: 1000 })] as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.ambiguous).toBe(1)
expect(res.review[0]).toMatchObject({ supplier_invoice_id: 'i1', reason: 'low_confidence' })
expect(mLink).not.toHaveBeenCalled()
})
it('does not auto-link when the top two candidates are within the margin', async () => {
queue([inv({ id: 'i1' })])
mFind.mockResolvedValueOnce([
cand({ je: 'v1', confidence: 0.95, amount: 1000 }),
cand({ je: 'v2', confidence: 0.95, amount: 1000, n: 2 }),
] as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.ambiguous).toBe(1)
expect(res.review[0].reason).toBe('multiple_candidates')
expect(mLink).not.toHaveBeenCalled()
})
it('demotes BOTH invoices when one voucher is the top pick for two of them', async () => {
queue([inv({ id: 'i1', remaining: 1000 }), inv({ id: 'i2', remaining: 1000 })])
// Each invoice has exactly one strong candidate — but it is the SAME voucher.
mFind
.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.ambiguous).toBe(2)
expect(res.review.every((r) => r.reason === 'voucher_contested')).toBe(true)
expect(mLink).not.toHaveBeenCalled()
})
it('excludes a voucher already consumed as a payment on another invoice', async () => {
queue([inv({ id: 'i1' })], [{ journal_entry_id: 'v1' }])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.99, amount: 1000 })] as never)
const res = await run()
expect(res.unmatched).toBe(1)
expect(res.autoLinked).toBe(0)
expect(mLink).not.toHaveBeenCalled()
})
it('routes a candidate whose AP debit exceeds the remaining to review', async () => {
queue([inv({ id: 'i1', total: 1000, remaining: 500 })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.99, amount: 1000 })] as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.review[0].reason).toBe('amount_exceeds_remaining')
expect(mLink).not.toHaveBeenCalled()
})
it('dryRun produces the plan without writing', async () => {
queue([inv({ id: 'i1', remaining: 1000 })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
const res = await reconcileSupplierInvoiceVouchers({
supabase: {} as never,
companyId: 'c1',
userId: 'u1',
dryRun: true,
})
expect(res.autoLinked).toBe(1)
expect(res.links[0]).toMatchObject({ journal_entry_id: 'v1', invoice_status: 'paid' })
expect(mLink).not.toHaveBeenCalled()
})
it('skips credit notes and zero-remaining invoices entirely', async () => {
queue([inv({ id: 'i1', isCredit: true }), inv({ id: 'i2', remaining: 0 })])
const res = await run()
expect(res.scanned).toBe(0)
expect(res.autoLinked).toBe(0)
expect(mFind).not.toHaveBeenCalled()
})
it('surfaces an RPC rejection as review rather than a successful link', async () => {
queue([inv({ id: 'i1', remaining: 1000 })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
mLink.mockResolvedValueOnce({ ok: false, code: 'LINK_SI_VOUCHER_ALREADY_LINKED' } as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.ambiguous).toBe(1)
expect(res.review[0].reason).toBe('voucher_contested')
})
})
@@ -0,0 +1,370 @@
/**
* Bulk reconcile supplier invoices to already-posted GL payment vouchers.
*
* Context: when a company is migrated from another system (e.g. Fortnox via the
* arcim-migration extension), the general ledger — including the bank-payment
* vouchers that settle accounts payable (Dr 2440 / Cr 1930) — is imported
* separately via SIE. Supplier invoices are imported as standalone
* `supplier_invoices` rows with NO link to those vouchers (the entity mapper
* never sets `payment_journal_entry_id`). Fortnox is queried with
* `?filter=unpaid`, so an invoice whose payment was booked in the source GL but
* never registered against the leverantörsfaktura object arrives here as an
* open payable. Once its due date passes the nightly cron flips it to
* `overdue` — even though the settling voucher already exists in the GL.
*
* This pass links each open payable to its matching posted voucher (reusing the
* exact same matcher + RPC behind the manual "Markera som betald → Befintlig
* verifikation" UI flow), so genuinely-settled invoices show as paid instead of
* falsely overdue. It NEVER creates, edits, or deletes a journal entry — it only
* inserts a `supplier_invoice_payments` row pointing at the existing voucher and
* advances the invoice's paid/remaining/status (all via the atomic
* `link_supplier_invoice_to_voucher` RPC).
*
* Safety: auto-linking is intentionally conservative. A voucher is linked
* automatically only when the match is unambiguous (see AUTO_LINK_* constants
* and the uniqueness rules below). Everything else is surfaced for manual review
* rather than guessed at. The function is idempotent and order-independent — it
* can be re-run any time after both halves of a migration exist.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
findMatchingVouchersForSupplierInvoice,
linkSupplierInvoiceToVoucher,
type SupplierVoucherCandidate,
} from './supplier-voucher-matching'
import type { SupplierInvoice, Supplier } from '@/types'
const log = createLogger('bulk-reconcile-supplier-vouchers')
/**
* Minimum confidence for an UNATTENDED auto-link. 0.95 = OCR/invoice-number hit
* (0.99) or exact-remaining-amount AND supplier-name corroboration (0.95).
* Amount-only matches (0.80, even with the +0.05 date bump → 0.85) are
* deliberately excluded — too many invoices share round amounts.
*/
const AUTO_LINK_MIN_CONFIDENCE = 0.95
/**
* Required confidence gap between the top candidate and the runner-up. A near
* tie means two vouchers look equally plausible → not safe to auto-pick. A
* margin (not exact equality) absorbs the ±0.05 date-proximity perturbation.
*/
const AUTO_LINK_MIN_MARGIN = 0.1
/** 0.5 öre — mirrors the tolerance used across the matching/RPC paths. */
const AMOUNT_TOLERANCE = 0.005
/** Safety cap on invoices processed in a single run (Vercel 300s budget). */
const DEFAULT_MAX_INVOICES = 2000
/** Supplier-invoice statuses that represent an open payable. */
const PAYABLE_STATUSES = ['registered', 'approved', 'overdue', 'partially_paid']
type ReconcileInvoiceRow = SupplierInvoice & {
is_credit_note?: boolean | null
supplier?: { id: string; name: string } | null
}
export type ReconcileReviewReason =
| 'multiple_candidates' // ≥2 candidates within the auto-link margin
| 'low_confidence' // best candidate below AUTO_LINK_MIN_CONFIDENCE
| 'amount_exceeds_remaining' // best candidate would overpay the invoice
| 'voucher_contested' // one voucher is the top pick for >1 invoice, or RPC rejected
export interface ReconcileLink {
supplier_invoice_id: string
supplier_invoice_number: string | null
journal_entry_id: string
payment_amount: number
invoice_status: 'paid' | 'partially_paid'
confidence: number
match_reason: string
}
export interface ReconcileReviewItem {
supplier_invoice_id: string
supplier_invoice_number: string | null
reason: ReconcileReviewReason
candidates: SupplierVoucherCandidate[]
}
export interface ReconcileResult {
/** Open payables considered (after credit-note / zero-remaining filtering). */
scanned: number
/** Invoices auto-linked to a voucher (or that would be, when dryRun). */
autoLinked: number
/** Invoices with candidate(s) but not safe to auto-link — need manual review. */
ambiguous: number
/** Invoices with no eligible voucher candidate at all. */
unmatched: number
/** True when more payables existed than `maxInvoices` and the rest were skipped. */
capped: boolean
links: ReconcileLink[]
review: ReconcileReviewItem[]
}
export interface ReconcileOptions {
supabase: SupabaseClient
companyId: string
/** Real user id — written onto the supplier_invoice_payments row + emitted event. */
userId: string
/** Compute the plan without writing. Default false. */
dryRun?: boolean
/** Max invoices to process in one run. Default 2000. */
maxInvoices?: number
onProgress?: (done: number, total: number) => void
}
const SELECT_COLUMNS =
'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, is_credit_note, supplier:suppliers(id, name)'
function remainingOf(inv: ReconcileInvoiceRow): number {
if (typeof inv.remaining_amount === 'number') return Math.max(0, inv.remaining_amount)
return Math.max(0, Math.round((inv.total - (inv.paid_amount ?? 0)) * 100) / 100)
}
/**
* Link open supplier-invoice payables to their matching already-posted GL
* vouchers. See file header for the full rationale and guarantees.
*/
export async function reconcileSupplierInvoiceVouchers(
opts: ReconcileOptions,
): Promise<ReconcileResult> {
const { supabase, companyId, userId, dryRun = false } = opts
const maxInvoices = opts.maxInvoices ?? DEFAULT_MAX_INVOICES
const result: ReconcileResult = {
scanned: 0,
autoLinked: 0,
ambiguous: 0,
unmatched: 0,
capped: false,
links: [],
review: [],
}
// 1. Open payables with an outstanding balance, excluding credit notes.
// Deterministic order so re-runs and the cross-invoice uniqueness pass are
// stable. Fully-paid invoices ('paid') are excluded by the status filter,
// making re-runs naturally idempotent.
const invoices = await fetchAllRows<ReconcileInvoiceRow>(
({ from, to }) =>
supabase
.from('supplier_invoices')
.select(SELECT_COLUMNS)
.eq('company_id', companyId)
.in('status', PAYABLE_STATUSES)
.order('due_date', { ascending: true })
.order('id', { ascending: true })
.range(from, to) as unknown as PromiseLike<{
// The `supplier:suppliers(id, name)` join makes PostgREST infer `supplier`
// as an array; ReconcileInvoiceRow models the runtime single-object shape.
data: ReconcileInvoiceRow[] | null
error: { message: string } | null
}>,
)
const payables = invoices.filter(
(inv) => !inv.is_credit_note && remainingOf(inv) > AMOUNT_TOLERANCE,
)
const toProcess = payables.slice(0, maxInvoices)
if (payables.length > maxInvoices) {
result.capped = true
log.warn('reconcile capped to maxInvoices — remaining payables left for a later run', {
companyId,
totalPayables: payables.length,
cap: maxInvoices,
})
}
// 2. Pre-load every voucher already consumed as a supplier payment (for ANY
// invoice in the company). Neither the matcher nor the RPC stop the SAME
// voucher being linked to a SECOND invoice, so we enforce exclusivity here.
const existingPayments = await fetchAllRows<{ journal_entry_id: string | null }>(({ from, to }) =>
supabase
.from('supplier_invoice_payments')
.select('journal_entry_id')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.range(from, to),
)
const consumedVouchers = new Set(
existingPayments
.map((p) => p.journal_entry_id)
.filter((id): id is string => !!id),
)
// 3. Per-invoice candidate gathering (read-only). Decide auto-eligibility.
interface Plan {
invoice: ReconcileInvoiceRow
candidates: SupplierVoucherCandidate[]
top?: SupplierVoucherCandidate
}
const autoCandidatePlans: Plan[] = []
for (const invoice of toProcess) {
result.scanned++
const candidates = await findMatchingVouchersForSupplierInvoice(
supabase,
companyId,
invoice as unknown as SupplierInvoice & { supplier?: Supplier },
{ limit: 5 },
)
// Drop vouchers already used elsewhere in the company.
const fresh = candidates.filter((c) => !consumedVouchers.has(c.journal_entry_id))
if (fresh.length === 0) {
result.unmatched++
continue
}
const top = fresh[0]
const runnerUp = fresh[1]
const remaining = remainingOf(invoice)
const confidentEnough = top.confidence >= AUTO_LINK_MIN_CONFIDENCE
const clearMargin = !runnerUp || top.confidence - runnerUp.confidence >= AUTO_LINK_MIN_MARGIN
// The RPC rejects a voucher whose AP debit exceeds the remaining amount; an
// OCR match (which ignores amount) could trip this, so screen it out here.
const amountFits = top.ap_debit_amount <= remaining + AMOUNT_TOLERANCE
if (confidentEnough && clearMargin && amountFits) {
autoCandidatePlans.push({ invoice, candidates: fresh, top })
} else {
result.ambiguous++
result.review.push({
supplier_invoice_id: invoice.id,
supplier_invoice_number: invoice.supplier_invoice_number ?? null,
reason: !confidentEnough
? 'low_confidence'
: !amountFits
? 'amount_exceeds_remaining'
: 'multiple_candidates',
candidates: fresh,
})
}
}
// 4. Cross-invoice uniqueness: if one voucher is the top auto-pick for more
// than one invoice (e.g. two identical 5 000 kr invoices both grabbing the
// same 5 000 kr voucher), auto-link NONE of them — demote all to review.
const claimsByVoucher = new Map<string, Plan[]>()
for (const plan of autoCandidatePlans) {
const key = plan.top!.journal_entry_id
const arr = claimsByVoucher.get(key) ?? []
arr.push(plan)
claimsByVoucher.set(key, arr)
}
const safePlans: Plan[] = []
for (const claimants of claimsByVoucher.values()) {
if (claimants.length === 1) {
safePlans.push(claimants[0])
} else {
for (const c of claimants) {
result.ambiguous++
result.review.push({
supplier_invoice_id: c.invoice.id,
supplier_invoice_number: c.invoice.supplier_invoice_number ?? null,
reason: 'voucher_contested',
candidates: c.candidates,
})
}
}
}
// 5. Link the unambiguous plans. Deterministic order; respect exclusivity
// across the batch via consumedVouchers.
safePlans.sort(
(a, b) =>
(a.invoice.due_date ?? '').localeCompare(b.invoice.due_date ?? '') ||
a.invoice.id.localeCompare(b.invoice.id),
)
let done = 0
for (const plan of safePlans) {
const top = plan.top!
const remaining = remainingOf(plan.invoice)
// Defensive: a voucher consumed earlier in THIS batch is off-limits.
if (consumedVouchers.has(top.journal_entry_id)) {
result.ambiguous++
result.review.push({
supplier_invoice_id: plan.invoice.id,
supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null,
reason: 'voucher_contested',
candidates: plan.candidates,
})
continue
}
if (dryRun) {
const willBeFullyPaid = top.ap_debit_amount >= remaining - AMOUNT_TOLERANCE
result.autoLinked++
result.links.push({
supplier_invoice_id: plan.invoice.id,
supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null,
journal_entry_id: top.journal_entry_id,
payment_amount: Math.min(top.ap_debit_amount, remaining),
invoice_status: willBeFullyPaid ? 'paid' : 'partially_paid',
confidence: top.confidence,
match_reason: top.match_reason,
})
consumedVouchers.add(top.journal_entry_id)
done++
opts.onProgress?.(done, safePlans.length)
continue
}
const outcome = await linkSupplierInvoiceToVoucher(supabase, userId, companyId, {
supplierInvoiceId: plan.invoice.id,
journalEntryId: top.journal_entry_id,
notes: `Auto-länkad vid avstämning (${Math.round(top.confidence * 100)}% säkerhet): ${top.match_reason}`,
})
if (outcome.ok) {
result.autoLinked++
consumedVouchers.add(top.journal_entry_id)
result.links.push({
supplier_invoice_id: plan.invoice.id,
supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null,
journal_entry_id: top.journal_entry_id,
payment_amount: outcome.result.paymentAmount,
invoice_status: outcome.result.invoiceStatus,
confidence: top.confidence,
match_reason: top.match_reason,
})
} else {
// The RPC re-validates atomically; a rejection here (race, already-linked,
// amount drift) means it isn't a clean auto-link — surface it.
result.ambiguous++
result.review.push({
supplier_invoice_id: plan.invoice.id,
supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null,
reason: 'voucher_contested',
candidates: plan.candidates,
})
log.warn('auto-link rejected by RPC', {
companyId,
supplierInvoiceId: plan.invoice.id,
journalEntryId: top.journal_entry_id,
code: outcome.code,
})
}
done++
opts.onProgress?.(done, safePlans.length)
}
log.info('reconcile complete', {
companyId,
dryRun,
scanned: result.scanned,
autoLinked: result.autoLinked,
ambiguous: result.ambiguous,
unmatched: result.unmatched,
capped: result.capped,
})
return result
}
@@ -0,0 +1,128 @@
/**
* Unit tests for commitLinkSupplierInvoiceVoucher, driven through the public
* commitPendingOperation dispatcher.
*
* The MCP tool gnubok_link_supplier_invoice_to_voucher stages a
* 'link_supplier_invoice_voucher' pending_operation; this dispatcher picks it up
* and the executor delegates to linkSupplierInvoiceToVoucher (the atomic
* link_supplier_invoice_to_voucher RPC). The RPC itself is covered by
* lib/invoices/__tests__/supplier-voucher-matching{,.pg}.test.ts — these tests
* focus on the dispatcher/executor wiring + status mapping.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createQueuedMockSupabase, makeSupplierInvoice } from '@/tests/helpers'
import type { PendingOperation } from '@/types'
import { commitPendingOperation } from '../commit'
const SI_UUID = '550e8400-e29b-41d4-a716-446655440010'
const JE_UUID = '550e8400-e29b-41d4-a716-446655440011'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'link_supplier_invoice_voucher',
status: 'pending',
title: 'test',
params: {},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-06-01T00:00:00Z',
resolved_at: null,
updated_at: '2026-06-01T00:00:00Z',
...overrides,
} as PendingOperation
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: link_supplier_invoice_voucher', () => {
it('returns 400 when supplier_invoice_id is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's reject update
const op = makePendingOp({ params: { journal_entry_id: JE_UUID } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/supplier_invoice_id/i)
})
it('happy path: links the verifikat and marks the supplier invoice paid', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
vi.spyOn(eventBus, 'emit').mockResolvedValue(undefined)
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
// executor -> linkSupplierInvoiceToVoucher -> RPC
enqueue({
data: {
ok: true,
payment_id: 'sip-1',
invoice_status: 'paid',
paid_amount: 1000,
remaining_amount: 0,
payment_amount: 1000,
journal_entry_id: JE_UUID,
currency: 'SEK',
},
error: null,
})
// post-link invoice re-fetch for the event payload
enqueue({
data: makeSupplierInvoice({ id: SI_UUID, status: 'paid', total: 1000, remaining_amount: 0 }),
error: null,
})
enqueue({ data: null, error: null }) // dispatcher commit update
const op = makePendingOp({ params: { supplier_invoice_id: SI_UUID, journal_entry_id: JE_UUID } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
invoice_status: 'paid',
paid_amount: 1000,
remaining_amount: 0,
payment_amount: 1000,
payment_id: 'sip-1',
journal_entry_id: JE_UUID,
})
})
it('auto-rejects with 404 when the RPC reports the invoice is gone', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { ok: false, code: 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND' }, error: null }) // RPC
enqueue({ data: null, error: null }) // dispatcher's auto-reject update
const op = makePendingOp({ params: { supplier_invoice_id: SI_UUID, journal_entry_id: JE_UUID } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(404)
})
it('auto-rejects with 409 when the verifikat is already linked', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { ok: false, code: 'LINK_SI_VOUCHER_ALREADY_LINKED' }, error: null }) // RPC
enqueue({ data: null, error: null }) // dispatcher's auto-reject update
const op = makePendingOp({ params: { supplier_invoice_id: SI_UUID, journal_entry_id: JE_UUID } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
})
+51
View File
@@ -40,6 +40,7 @@ import {
createSupplierInvoiceRegistrationEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching'
import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { parseSIEFile } from '@/lib/import/sie-parser'
@@ -1038,6 +1039,48 @@ async function commitLinkInvoiceVoucher(
}
}
async function commitLinkSupplierInvoiceVoucher(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const supplierInvoiceId = params.supplier_invoice_id as string | undefined
const journalEntryId = params.journal_entry_id as string | undefined
const notes = (params.notes as string | undefined) ?? undefined
if (!supplierInvoiceId || !journalEntryId) {
return { error: 'supplier_invoice_id and journal_entry_id are required', status: 400 }
}
const outcome = await linkSupplierInvoiceToVoucher(supabase, userId, companyId, {
supplierInvoiceId,
journalEntryId,
notes,
})
if (!outcome.ok) {
const entry = getErrorEntry(outcome.code)
// 404/409 are auto-rejected by the dispatcher (the user can re-stage with
// adjusted inputs); 400 surfaces as a normal failure so the UI can explain.
return {
error: entry?.message_en ?? outcome.code,
status: entry?.httpStatus ?? 500,
}
}
return {
data: {
invoice_status: outcome.result.invoiceStatus,
paid_amount: outcome.result.paidAmount,
remaining_amount: outcome.result.remainingAmount,
payment_amount: outcome.result.paymentAmount,
payment_id: outcome.result.paymentId,
journal_entry_id: outcome.result.journalEntryId,
},
}
}
// ── Stream 1 Phase 1 + follow-up executors ───────────────────────
async function commitClosePeriod(
@@ -1614,6 +1657,11 @@ async function commitCreateSupplierInvoiceFromInbox(
vat_code: null,
vat_rate: vatRate,
vat_amount: vatAmt,
// For reverse charge the buyer self-assesses VAT; carry an explicit
// statutory rate when staged, else null (engine defaults to 25%).
reverse_charge_rate: reverseCharge
? ([0.06, 0.12, 0.25].includes(Number(item.reverse_charge_rate)) ? Number(item.reverse_charge_rate) : null)
: null,
}
})
@@ -2909,6 +2957,9 @@ export async function commitPendingOperation(
case 'link_invoice_voucher':
result = await commitLinkInvoiceVoucher(supabase, userId, companyId, pendingOp.params)
break
case 'link_supplier_invoice_voucher':
result = await commitLinkSupplierInvoiceVoucher(supabase, userId, companyId, pendingOp.params)
break
case 'close_period':
result = await commitClosePeriod(supabase, userId, companyId, pendingOp.params)
break
+5
View File
@@ -30,6 +30,11 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// entry is created or modified. Sits next to match_transaction_invoice
// semantically — both attach an existing booking to an invoice.
link_invoice_voucher: 'medium',
// Supplier-side mirror of link_invoice_voucher: link an existing posted
// verifikat (Dr 2440) as payment for a leverantörsfaktura. Reversible by
// deleting the supplier_invoice_payments row and reverting status; no journal
// entry is created or modified.
link_supplier_invoice_voucher: 'medium',
create_invoice: 'medium', // creates as draft; sending is a separate op
create_transaction: 'medium', // ingests an uncategorized row; reversible by delete
// Supplier master data carries payment-routing fields (IBAN, BIC, bankgiro,
@@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest'
import { mapFortnoxToSupplierInvoice, mapFortnoxToSalesInvoice } from '../mapper'
/**
* Guards the paid-status hardening: deriveInvoiceStatus and paymentStatus.paid
* share one isFullyPaid() source of truth, so status === 'paid' iff
* paymentStatus.paid (for non-cancelled / non-credit rows). An ABSENT Balance
* must never be read as paid — on either the supplier OR the sales path.
*/
function supplierRaw(over: Record<string, unknown>): Record<string, unknown> {
return {
GivenNumber: '100',
Total: 1000,
InvoiceDate: '2026-01-10',
DueDate: '2026-02-10',
SupplierName: 'Leverantör AB',
Booked: true,
...over,
}
}
function salesRaw(over: Record<string, unknown>): Record<string, unknown> {
return {
DocumentNumber: '200',
Total: 1000,
InvoiceDate: '2026-01-10',
DueDate: '2026-02-10',
CustomerName: 'Kund AB',
Sent: true,
...over,
}
}
describe('Fortnox mapper — paid-status consistency', () => {
it('supplier: absent Balance is NOT paid (defaults to unpaid, not 0)', () => {
const dto = mapFortnoxToSupplierInvoice(supplierRaw({})) // no Balance key
expect(dto.status).toBe('booked')
expect(dto.paymentStatus.paid).toBe(false)
expect(dto.paymentStatus.balance.value).toBe(1000)
})
it('supplier: Balance 0 → paid and status paid', () => {
const dto = mapFortnoxToSupplierInvoice(supplierRaw({ Balance: 0 }))
expect(dto.status).toBe('paid')
expect(dto.paymentStatus.paid).toBe(true)
})
it('supplier: positive Balance → unpaid', () => {
const dto = mapFortnoxToSupplierInvoice(supplierRaw({ Balance: 250 }))
expect(dto.status).toBe('booked')
expect(dto.paymentStatus.paid).toBe(false)
expect(dto.paymentStatus.balance.value).toBe(250)
})
it('supplier: FullyPaid flag with absent Balance keeps status and paid CONSISTENT', () => {
// Previously deriveInvoiceStatus said paid while paymentStatus.paid said unpaid.
const dto = mapFortnoxToSupplierInvoice(supplierRaw({ FullyPaid: true }))
expect(dto.status).toBe('paid')
expect(dto.paymentStatus.paid).toBe(true)
// paid ⇒ no outstanding balance, even though the raw payload omits Balance
// (previously balance fell back to the full total, contradicting paid=true).
expect(dto.paymentStatus.balance.value).toBe(0)
})
it('sales: absent Balance is NOT paid (no false-paid on the sales path)', () => {
const dto = mapFortnoxToSalesInvoice(salesRaw({})) // no Balance key
expect(dto.status).toBe('sent')
expect(dto.paymentStatus.paid).toBe(false)
expect(dto.paymentStatus.balance.value).toBe(1000)
})
it('sales: Balance 0 → paid and status paid', () => {
const dto = mapFortnoxToSalesInvoice(salesRaw({ Balance: 0 }))
expect(dto.status).toBe('paid')
expect(dto.paymentStatus.paid).toBe(true)
})
it('sales: FullyPaid flag with absent Balance → paid with zero balance', () => {
const dto = mapFortnoxToSalesInvoice(salesRaw({ FullyPaid: true }))
expect(dto.status).toBe('paid')
expect(dto.paymentStatus.paid).toBe(true)
expect(dto.paymentStatus.balance.value).toBe(0)
})
it('status === paid iff paymentStatus.paid across a matrix (both paths)', () => {
const balances = [undefined, 0, 0.004, 250, 1000]
const flags = [undefined, true]
for (const Balance of balances) {
for (const FullyPaid of flags) {
const over: Record<string, unknown> = { FullyPaid }
if (Balance !== undefined) over.Balance = Balance
for (const dto of [
mapFortnoxToSupplierInvoice(supplierRaw(over)),
mapFortnoxToSalesInvoice(salesRaw(over)),
]) {
expect(
dto.status === 'paid',
`Balance=${Balance} FullyPaid=${FullyPaid}`,
).toBe(dto.paymentStatus.paid)
// Invariant: paid ⇒ balance zeroed (never "fully paid yet full balance").
if (dto.paymentStatus.paid) {
expect(
dto.paymentStatus.balance.value,
`Balance=${Balance} FullyPaid=${FullyPaid}`,
).toBe(0)
}
}
}
}
})
})
+32 -5
View File
@@ -14,10 +14,22 @@ function amount(value: number | undefined | null, currency: string = 'SEK'): Amo
return { value: value ?? 0, currencyCode: currency };
}
/**
* Single source of truth for "is this invoice fully settled?", used by BOTH
* deriveInvoiceStatus and the paymentStatus.paid flag so they can never diverge.
* Numeric, not strict === 0, so a residual öre / float drift still reads as paid.
* Number(undefined ?? NaN) = NaN and NaN <= 0 is false, so an ABSENT Balance is
* treated as NOT paid (the supplier-invoice list payload omits Balance) — only an
* explicit FullyPaid flag or a present non-positive Balance counts as paid.
*/
function isFullyPaid(raw: Record<string, unknown>): boolean {
return raw['FullyPaid'] === true || Number(raw['Balance'] ?? NaN) <= 0;
}
function deriveInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
if (raw['Cancelled'] === true) return 'cancelled';
if (raw['Credit'] === true) return 'credited';
if (raw['FullyPaid'] === true || raw['Balance'] === 0) return 'paid';
if (isFullyPaid(raw)) return 'paid';
if (raw['Booked'] === true) return 'booked';
if (raw['Sent'] === true) return 'sent';
return 'draft';
@@ -49,7 +61,14 @@ function buildParty(name: string, orgNumber?: string, address?: Record<string, u
export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
const currency = (raw['Currency'] as string) ?? 'SEK';
const total = raw['Total'] as number ?? 0;
const balance = raw['Balance'] as number ?? 0;
// Default an ABSENT Balance to the full total (= fully unpaid), never 0, so a
// missing Balance never silently reads as paid. A present Balance (incl. 0) is
// used as-is. Mirrors the supplier path; paid-ness comes from isFullyPaid().
// When paid, force balance to 0 so the DTO is internally consistent
// (paid ⇒ nothing outstanding): an explicit FullyPaid with no Balance field
// would otherwise leave balance = total alongside paid = true.
const paid = isFullyPaid(raw);
const balance = paid ? 0 : ((raw['Balance'] as number | undefined) ?? total);
const rows = (raw['InvoiceRows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
@@ -72,7 +91,7 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
};
const paymentStatus: PaymentStatusDto = {
paid: balance === 0 && total > 0,
paid,
balance: amount(balance, currency),
};
@@ -107,7 +126,15 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
export function mapFortnoxToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
const currency = (raw['Currency'] as string) ?? 'SEK';
const total = raw['Total'] as number ?? 0;
const balance = raw['Balance'] as number ?? 0;
// Default an ABSENT Balance to the full total (= fully unpaid), never 0.
// The supplier-invoice list is fetched with ?filter=unpaid, so a missing
// Balance must not be mistaken for "settled" — that would flip a genuinely
// open payable to paid downstream. A present Balance (incl. 0) is used as-is.
// When paid, force balance to 0 so the DTO is internally consistent
// (paid ⇒ nothing outstanding): an explicit FullyPaid with no Balance field
// would otherwise leave balance = total alongside paid = true.
const paid = isFullyPaid(raw);
const balance = paid ? 0 : ((raw['Balance'] as number | undefined) ?? total);
const rows = (raw['SupplierInvoiceRows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({
@@ -127,7 +154,7 @@ export function mapFortnoxToSupplierInvoice(raw: Record<string, unknown>): Suppl
};
const paymentStatus: PaymentStatusDto = {
paid: balance === 0 && total > 0,
paid,
balance: amount(balance, currency),
};
@@ -13,6 +13,7 @@ function makeBuilder() {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
@@ -1050,3 +1051,65 @@ describe('calculateVatDeclaration — parent/summary accounts', () => {
expect(result.rutor.ruta49).toBe(2719.55) // 9768 − 7048.45, owed (was −7048.45 pre-fix)
})
})
describe('calculateVatDeclaration — annual VAT spans the räkenskapsår', () => {
it('uses the fiscal period bounds for yearly when a fiscalPeriodId is given', async () => {
// Förlängt räkenskapsår (extended first year, 18 months) — annual VAT
// (helårsmoms) must cover the whole period, not the calendar year that
// period_start falls in. The first queued result feeds the fiscal_periods
// lookup, the second the journal lines, the third the entry counts.
results = [
{ data: { period_start: '2025-07-03', period_end: '2026-12-31' }, error: null },
{
data: [
{ account_number: '3001', debit_amount: 0, credit_amount: 21600 },
{ account_number: '2610', debit_amount: 0, credit_amount: 9768 },
{ account_number: '2641', debit_amount: 7048.45, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(
supabase, 'company-1', 'yearly', 2026, 1, 'accrual', { fiscalPeriodId: 'fp-1' },
)
expect(result.period.start).toBe('2025-07-03')
expect(result.period.end).toBe('2026-12-31')
expect(result.rutor.ruta05).toBe(21600)
expect(result.rutor.ruta10).toBe(9768)
expect(result.rutor.ruta48).toBe(7048.45)
})
it('falls back to the calendar year when the fiscal period cannot be resolved', async () => {
results = [
{ data: null, error: null }, // fiscal_periods lookup → not found
{ data: [], error: null }, // journal lines
{ data: [], error: null }, // entry counts
]
const result = await calculateVatDeclaration(
supabase, 'company-1', 'yearly', 2026, 1, 'accrual', { fiscalPeriodId: 'missing' },
)
expect(result.period.start).toBe('2026-01-01')
expect(result.period.end).toBe('2026-12-31')
})
it('ignores fiscalPeriodId for monthly periods (calendar month, no lookup)', async () => {
// No fiscal_periods lookup is made for monthly, so the first queued result
// is the journal lines — proving the räkenskapsår path is yearly-only.
results = [
{ data: [], error: null }, // journal lines
{ data: [], error: null }, // entry counts
]
const result = await calculateVatDeclaration(
supabase, 'company-1', 'monthly', 2026, 3, 'accrual', { fiscalPeriodId: 'fp-1' },
)
expect(result.period.start).toBe('2026-03-01')
expect(result.period.end).toBe('2026-03-31')
})
})
+6 -1
View File
@@ -296,12 +296,17 @@ async function generatePeriodReports(
let vatDeclaration: unknown = null
try {
const startDate = new Date(period.period_start)
// Annual VAT for an archive must cover the whole räkenskapsår, which may be
// extended/shortened — pass the fiscal period so the span isn't truncated to
// the calendar year that period_start happens to fall in.
vatDeclaration = await calculateVatDeclaration(
supabase,
companyId,
'yearly',
startDate.getFullYear(),
1
1,
'accrual',
{ fiscalPeriodId: period.id }
)
} catch {
// VAT declaration may fail if no relevant entries exist — skip gracefully
+45 -2
View File
@@ -185,6 +185,44 @@ function round(value: number): number {
return Math.round(value * 100) / 100
}
/**
* Resolve the start/end dates for a VAT period.
*
* Monthly and quarterly VAT periods are always calendar months/quarters
* (kalendermånad / kalenderkvartal per SFL 26 kap), so they use the plain
* calendar calculation.
*
* Annual VAT (helårsmoms), however, is reported per *räkenskapsår* — the
* beskattningsår — not per calendar year (SFL 26 kap 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<VatDeclaration> {
const { start, end } = calculatePeriodDates(periodType, year, period)
// For yearly VAT this resolves to the räkenskapsår bounds (when a fiscal
// period is supplied), not the calendar year — see resolvePeriodDates.
const { start, end } = await resolvePeriodDates(
supabase, companyId, periodType, year, period, options.fiscalPeriodId
)
// Fetch all posted journal entry lines on VAT-relevant accounts for the period
const lines = await fetchAllRows<{