feat(migration): link migrated invoices to their registration voucher (#1463) (#2024)

Visma and Fortnox migrations now carry each invoice's source voucher reference, and after the invoice steps a core linker resolves it against the SIE-imported ledger (voucher-ref resolver by date, corroborated by the 244x credit / 151x debit amount, posted only, unreferenced only) and writes registration_journal_entry_id / journal_entry_id. Anything ambiguous, mismatched or unresolved is reported and left NULL; journal entries are never written. The arcim-migration /reconcile endpoint can relink already-migrated companies. Payment vouchers are PR B. Refs #1463
This commit is contained in:
Jakob Wennberg
2026-08-30 11:52:14 +02:00
committed by GitHub
parent 683314b439
commit 521f437072
22 changed files with 2411 additions and 21 deletions
@@ -0,0 +1,576 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { roundOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
linkMigratedRegistrationVouchers,
type MigratedInvoiceLinkInput,
} from '../link-migrated-registration-vouchers'
/**
* The linker joins a migrated invoice to the SIE-imported verifikat that
* booked it in the source system (#1463). The tests pin the guardrails: a
* link is written only for exactly one posted, unclaimed, amount-corroborated
* verifikat, and every other case stays NULL with a reason. Journal tables
* are never written.
*
* Read order (one queued result per `.from()`): source-ref vouchers, fiscal
* periods, entry status, entry lines, supplier_invoices referencing the
* entries, invoices referencing the entries, then one update per link.
*/
const COMPANY = 'company-1'
const PERIOD_2025 = 'period-2025'
const periods = [
{ id: 'period-2024', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false, locked_at: null },
{ id: PERIOD_2025, period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false, locked_at: null },
]
function voucher(over: { id: string; series?: string | null; number?: number; period?: string; date?: string }) {
return {
id: over.id,
fiscal_period_id: over.period ?? PERIOD_2025,
entry_date: over.date ?? '2025-03-14',
source_voucher_series: over.series === undefined ? 'A' : over.series,
source_voucher_number: over.number ?? 329,
}
}
function line(entry: string, account: string, debit: number, credit: number, id = `${entry}-${account}-${debit}-${credit}`) {
return { id, journal_entry_id: entry, account_number: account, debit_amount: debit, credit_amount: credit }
}
/** A standard supplier registration voucher: Dr 5010 800, Dr 2641 200, Cr 2440 1000. */
function supplierLines(entry: string, total = 1000) {
const net = roundOre(total * 0.8)
const vat = roundOre(total - net)
return [line(entry, '5010', net, 0), line(entry, '2641', vat, 0), line(entry, '2440', 0, total)]
}
/** A standard customer registration voucher: Dr 1510 1000, Cr 3001 800, Cr 2611 200. */
function customerLines(entry: string, total = 1000) {
const net = roundOre(total * 0.8)
const vat = roundOre(total - net)
return [line(entry, '1510', total, 0), line(entry, '3001', 0, net), line(entry, '2611', 0, vat)]
}
function input(over: Partial<MigratedInvoiceLinkInput> & { invoiceId: string }): MigratedInvoiceLinkInput {
return {
kind: 'supplier',
sourceVoucher: { series: 'A', number: 329 },
invoiceDate: '2025-03-14',
totalSek: 1000,
invoiceNumber: `F-${over.invoiceId}`,
...over,
}
}
interface Queue {
vouchers?: unknown[]
entries?: unknown[]
lines?: unknown[]
supplierRefs?: unknown[]
customerRefs?: unknown[]
updates?: { data?: unknown; error?: unknown }[]
}
let mock: ReturnType<typeof createQueuedMockSupabase>
function queue(q: Queue) {
mock.enqueue({ data: q.vouchers ?? [] })
mock.enqueue({ data: periods })
if (q.entries !== undefined) mock.enqueue({ data: q.entries })
if (q.lines !== undefined) mock.enqueue({ data: q.lines })
if (q.supplierRefs !== undefined) mock.enqueue({ data: q.supplierRefs })
if (q.customerRefs !== undefined) mock.enqueue({ data: q.customerRefs })
for (const u of q.updates ?? []) mock.enqueue(u)
}
function run(invoices: MigratedInvoiceLinkInput[], dryRun = false) {
return linkMigratedRegistrationVouchers({
supabase: mock.supabase as unknown as SupabaseClient,
companyId: COMPANY,
invoices,
dryRun,
})
}
function updateCalls(table: 'supplier_invoices' | 'invoices') {
return mock.calls.filter((c) => c.table === table && c.method === 'update')
}
/**
* The filter calls chained onto the FIRST `update` on `table`, up to the next
* `from`. Asserting on these (not on every `.eq` the module ever made against
* the table) is what pins the tenancy and NULL guards to the write itself:
* the referencing-invoice read earlier also filters on company_id.
*/
function updateChain(table: 'supplier_invoices' | 'invoices') {
const start = mock.calls.findIndex((c) => c.table === table && c.method === 'update')
if (start < 0) return []
const chain: [string, unknown[]][] = []
for (let i = start + 1; i < mock.calls.length; i++) {
const c = mock.calls[i]
if (c.table !== table) break
chain.push([c.method, c.args])
}
return chain
}
beforeEach(() => {
mock = createQueuedMockSupabase()
})
describe('linkMigratedRegistrationVouchers', () => {
it('returns zeros and reads nothing for an empty input', async () => {
const result = await run([])
expect(result).toEqual({
scanned: 0, linked: 0, noRef: 0, refNotFetched: 0, unresolved: 0, ambiguous: 0, amountMismatch: 0, alreadyLinked: 0, reports: [],
})
expect(mock.calls).toHaveLength(0)
})
it('links a supplier invoice to the posted verifikat that credits 2440 with its SEK total', async () => {
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: supplierLines('je-1'),
supplierRefs: [],
customerRefs: [],
updates: [{ data: [{ id: 'si-1' }] }],
})
const result = await run([input({ invoiceId: 'si-1' })])
expect(result.linked).toBe(1)
expect(result.reports[0]).toMatchObject({ invoiceId: 'si-1', outcome: 'linked', journalEntryId: 'je-1' })
const [update] = updateCalls('supplier_invoices')
expect(update.args[0]).toEqual({ registration_journal_entry_id: 'je-1' })
// The write itself is scoped to the row, the company, and NULL only.
expect(updateChain('supplier_invoices')).toEqual(expect.arrayContaining([
['eq', ['id', 'si-1']],
['eq', ['company_id', COMPANY]],
['is', ['registration_journal_entry_id', null]],
['select', ['id']],
]))
expect(updateCalls('invoices')).toHaveLength(0)
})
it('links a customer invoice to the posted verifikat that debits 1510 with its SEK total', async () => {
queue({
vouchers: [voucher({ id: 'je-2', number: 12 })],
entries: [{ id: 'je-2', status: 'posted' }],
lines: customerLines('je-2', 2500),
supplierRefs: [],
customerRefs: [],
updates: [{ data: [{ id: 'inv-1' }] }],
})
const result = await run([
input({ invoiceId: 'inv-1', kind: 'customer', sourceVoucher: { series: 'A', number: 12 }, totalSek: 2500 }),
])
expect(result.linked).toBe(1)
const [update] = updateCalls('invoices')
expect(update.args[0]).toEqual({ journal_entry_id: 'je-2' })
expect(updateChain('invoices')).toEqual(expect.arrayContaining([
['eq', ['id', 'inv-1']],
['eq', ['company_id', COMPANY]],
['is', ['journal_entry_id', null]],
['select', ['id']],
]))
expect(updateCalls('supplier_invoices')).toHaveLength(0)
})
it('resolves a series-less ref when exactly one verifikat carries the number in that year', async () => {
queue({
vouchers: [voucher({ id: 'je-1' }), voucher({ id: 'je-old', period: 'period-2024', date: '2024-03-14' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: supplierLines('je-1'),
supplierRefs: [],
customerRefs: [],
updates: [{ data: [{ id: 'si-1' }] }],
})
const result = await run([input({ invoiceId: 'si-1', sourceVoucher: { series: null, number: 329 } })])
expect(result.linked).toBe(1)
expect(result.reports[0].journalEntryId).toBe('je-1')
})
it('reports noRef and writes nothing when the provider named no voucher', async () => {
queue({ vouchers: [voucher({ id: 'je-1' })] })
const result = await run([input({ invoiceId: 'si-1', sourceVoucher: null })])
expect(result.noRef).toBe(1)
expect(result.linked).toBe(0)
expect(result.reports[0]).toMatchObject({ outcome: 'noRef' })
expect(updateCalls('supplier_invoices')).toHaveLength(0)
// Only the two index reads happened.
expect(mock.supabase.from).toHaveBeenCalledTimes(2)
})
it('reports refNotFetched (not noRef) when the provider detail payload was never fetched', async () => {
queue({ vouchers: [voucher({ id: 'je-1' })] })
const result = await run([input({ invoiceId: 'si-1', sourceVoucher: undefined, refNotFetched: true })])
expect(result.refNotFetched).toBe(1)
expect(result.noRef).toBe(0)
expect(result.reports[0]).toMatchObject({ outcome: 'refNotFetched' })
expect(result.reports[0].reason).toContain('not fetched')
expect(updateCalls('supplier_invoices')).toHaveLength(0)
})
it('does not link a December invoice to the previous year\'s same-numbered voucher (date corridor)', async () => {
// Fortnox restarts numbering per year. An invoice dated 2024-12-28 that the
// source booked in January 2025 carries FY2025's "B3"; resolving in the
// invoice-date year finds FY2024's B3, an unrelated January-2024 voucher
// from a recurring supplier with the identical amount. It must stay NULL.
queue({
vouchers: [voucher({ id: 'je-wrong-year', series: 'B', number: 3, period: 'period-2024', date: '2024-01-10' })],
})
const result = await run([
input({ invoiceId: 'si-1', sourceVoucher: { series: 'B', number: 3 }, invoiceDate: '2024-12-28', totalSek: 1000 }),
])
expect(result.linked).toBe(0)
expect(result.unresolved).toBe(1)
expect(result.reports[0].reason).toMatch(/dated 2024-01-10.*before the invoice date 2024-12-28/)
expect(updateCalls('supplier_invoices')).toHaveLength(0)
// Never reached the corroboration reads.
expect(mock.supabase.from).toHaveBeenCalledTimes(2)
})
it('applies the date corridor to series-less refs too', async () => {
queue({
vouchers: [voucher({ id: 'je-far', series: 'A', number: 329, date: '2025-11-30' })],
})
const result = await run([
input({ invoiceId: 'si-1', sourceVoucher: { series: null, number: 329 }, invoiceDate: '2025-03-14' }),
])
expect(result.unresolved).toBe(1)
expect(result.reports[0].reason).toContain('after the invoice date')
})
it('accepts a verifikat booked a few weeks after, or a few days before, the invoice date', async () => {
queue({
vouchers: [
voucher({ id: 'je-late', series: 'A', number: 329, date: '2025-04-30' }),
voucher({ id: 'je-early', series: 'A', number: 330, date: '2025-03-04' }),
],
entries: [{ id: 'je-late', status: 'posted' }, { id: 'je-early', status: 'posted' }],
lines: [...supplierLines('je-late'), ...supplierLines('je-early')],
supplierRefs: [],
customerRefs: [],
updates: [{ data: [{ id: 'si-1' }] }, { data: [{ id: 'si-2' }] }],
})
const result = await run([
input({ invoiceId: 'si-1', invoiceDate: '2025-03-14' }),
input({ invoiceId: 'si-2', sourceVoucher: { series: 'A', number: 330 }, invoiceDate: '2025-03-14' }),
])
expect(result.linked).toBe(2)
})
it('reports unresolved when no verifikat carries the ref in the invoice year', async () => {
queue({ vouchers: [voucher({ id: 'je-old', period: 'period-2024', date: '2024-03-14' })] })
const result = await run([input({ invoiceId: 'si-1' })])
expect(result.unresolved).toBe(1)
expect(result.reports[0]).toMatchObject({ outcome: 'unresolved' })
expect(result.reports[0].reason).toContain('A329')
expect(updateCalls('supplier_invoices')).toHaveLength(0)
})
it('reports unresolved when no fiscal period covers the invoice date', async () => {
queue({ vouchers: [voucher({ id: 'je-1' })] })
const result = await run([input({ invoiceId: 'si-1', invoiceDate: '2019-05-05' })])
expect(result.unresolved).toBe(1)
expect(result.reports[0].reason).toContain('no fiscal period')
})
it('reports ambiguous when two verifikat in the year carry the same source ref', async () => {
queue({ vouchers: [voucher({ id: 'je-1' }), voucher({ id: 'je-1b' })] })
const result = await run([input({ invoiceId: 'si-1' })])
expect(result.ambiguous).toBe(1)
expect(result.reports[0]).toMatchObject({ outcome: 'ambiguous' })
expect(updateCalls('supplier_invoices')).toHaveLength(0)
})
it('reports ambiguous for a series-less ref that hits several series', async () => {
queue({ vouchers: [voucher({ id: 'je-1', series: 'A' }), voucher({ id: 'je-1b', series: 'B' })] })
const result = await run([input({ invoiceId: 'si-1', sourceVoucher: { series: null, number: 329 } })])
expect(result.ambiguous).toBe(1)
expect(result.reports[0].reason).toContain('2 series')
})
it('reports ambiguous for both invoices when they resolve to the same verifikat', async () => {
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: supplierLines('je-1'),
supplierRefs: [],
customerRefs: [],
})
const result = await run([input({ invoiceId: 'si-1' }), input({ invoiceId: 'si-2' })])
expect(result.ambiguous).toBe(2)
expect(result.linked).toBe(0)
expect(updateCalls('supplier_invoices')).toHaveLength(0)
})
it('reports amountMismatch when the 2440 net credit differs from the invoice total', async () => {
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: supplierLines('je-1', 999),
supplierRefs: [],
customerRefs: [],
})
const result = await run([input({ invoiceId: 'si-1', totalSek: 1000 })])
expect(result.amountMismatch).toBe(1)
expect(result.reports[0].reason).toContain('999')
expect(updateCalls('supplier_invoices')).toHaveLength(0)
})
it('explains a foreign-currency mismatch as a rate difference, and still does not link', async () => {
// total_sek comes from our Riksbanken index; the source booked 2440 at its
// own rate. The amounts differ, so the bucket is amountMismatch, but the
// reason must say why instead of implying a bookkeeping discrepancy.
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: supplierLines('je-1', 11240),
supplierRefs: [],
customerRefs: [],
})
const result = await run([input({ invoiceId: 'si-1', totalSek: 11200, currencyCode: 'EUR' })])
expect(result.amountMismatch).toBe(1)
expect(result.linked).toBe(0)
expect(result.reports[0].reason).toContain('EUR')
expect(result.reports[0].reason).toContain('rate')
expect(updateCalls('supplier_invoices')).toHaveLength(0)
})
it('reports unresolved (never links) on a kontantmetod-style voucher with no 244x line at all', async () => {
// Cash-method companies book the expense on payment: Dr 5010 / Cr 1930.
// The provider may still name that voucher; it is NOT a registration
// voucher, so it is neither linked nor counted as an amount mismatch.
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: [line('je-1', '5010', 1000, 0), line('je-1', '1930', 0, 1000)],
supplierRefs: [],
customerRefs: [],
})
const result = await run([input({ invoiceId: 'si-1' })])
expect(result.unresolved).toBe(1)
expect(result.amountMismatch).toBe(0)
expect(result.linked).toBe(0)
expect(result.reports[0].reason).toContain('no 244x line')
expect(updateCalls('supplier_invoices')).toHaveLength(0)
})
it('reports unresolved for a customer invoice whose named voucher has no 151x line', async () => {
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: [line('je-1', '1930', 1000, 0), line('je-1', '3001', 0, 1000)],
supplierRefs: [],
customerRefs: [],
})
const result = await run([input({ invoiceId: 'inv-1', kind: 'customer' })])
expect(result.unresolved).toBe(1)
expect(result.reports[0].reason).toContain('no 151x line')
expect(updateCalls('invoices')).toHaveLength(0)
})
it('reports amountMismatch when the invoice has no SEK total', async () => {
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: supplierLines('je-1'),
supplierRefs: [],
customerRefs: [],
})
const result = await run([input({ invoiceId: 'si-1', totalSek: null })])
expect(result.amountMismatch).toBe(1)
expect(result.reports[0].reason).toContain('no SEK total')
})
it('tolerates half an öre and sums several 244x lines (2440 + 2441)', async () => {
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: [
line('je-1', '5010', 1000.004, 0),
line('je-1', '2440', 0, 600),
line('je-1', '2441', 0, 400),
],
supplierRefs: [],
customerRefs: [],
updates: [{ data: [{ id: 'si-1' }] }],
})
const result = await run([input({ invoiceId: 'si-1', totalSek: 1000.004 })])
expect(result.linked).toBe(1)
})
it('reports alreadyLinked when another invoice already references the verifikat', async () => {
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: supplierLines('je-1'),
supplierRefs: [{ id: 'si-other', registration_journal_entry_id: 'je-1' }],
customerRefs: [],
})
const result = await run([input({ invoiceId: 'si-1' })])
expect(result.alreadyLinked).toBe(1)
expect(result.reports[0].reason).toContain('another invoice')
expect(updateCalls('supplier_invoices')).toHaveLength(0)
})
it('reports alreadyLinked (idempotent re-run) when the invoice itself already links the verifikat', async () => {
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: supplierLines('je-1'),
supplierRefs: [{ id: 'si-1', registration_journal_entry_id: 'je-1' }],
customerRefs: [],
})
const result = await run([input({ invoiceId: 'si-1' })])
expect(result.alreadyLinked).toBe(1)
expect(result.reports[0].reason).toContain('already links')
expect(updateCalls('supplier_invoices')).toHaveLength(0)
})
it('reports alreadyLinked when the NULL-guarded update matches no row', async () => {
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: supplierLines('je-1'),
supplierRefs: [],
customerRefs: [],
updates: [{ data: [] }],
})
const result = await run([input({ invoiceId: 'si-1' })])
expect(result.alreadyLinked).toBe(1)
expect(result.linked).toBe(0)
})
it('rejects a verifikat the company-scoped status read does not return (cross-company)', async () => {
queue({
vouchers: [voucher({ id: 'je-foreign' })],
entries: [],
lines: [],
supplierRefs: [],
customerRefs: [],
})
const result = await run([input({ invoiceId: 'si-1' })])
expect(result.unresolved).toBe(1)
expect(result.reports[0].reason).toContain('not found in this company')
expect(mock.findCalls('journal_entries', 'eq')).toEqual(
expect.arrayContaining([['company_id', COMPANY]]),
)
expect(updateCalls('supplier_invoices')).toHaveLength(0)
})
it('rejects a verifikat that is not posted', async () => {
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'reversed' }],
lines: supplierLines('je-1'),
supplierRefs: [],
customerRefs: [],
})
const result = await run([input({ invoiceId: 'si-1' })])
expect(result.unresolved).toBe(1)
expect(result.reports[0].reason).toContain('reversed')
})
it('dry run decides without writing', async () => {
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: supplierLines('je-1'),
supplierRefs: [],
customerRefs: [],
})
const result = await run([input({ invoiceId: 'si-1' })], true)
expect(result.linked).toBe(1)
expect(result.reports[0].reason).toContain('dry run')
expect(updateCalls('supplier_invoices')).toHaveLength(0)
})
it('throws on a database error instead of reporting a phantom outcome', async () => {
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: supplierLines('je-1'),
supplierRefs: [],
customerRefs: [],
updates: [{ data: null, error: { message: 'boom' } }],
})
await expect(run([input({ invoiceId: 'si-1' })])).rejects.toThrow(/boom/)
})
it('never writes to journal tables', async () => {
queue({
vouchers: [voucher({ id: 'je-1' })],
entries: [{ id: 'je-1', status: 'posted' }],
lines: supplierLines('je-1'),
supplierRefs: [],
customerRefs: [],
updates: [{ data: [{ id: 'si-1' }] }],
})
await run([input({ invoiceId: 'si-1' })])
const journalWrites = mock.calls.filter(
(c) => (c.table === 'journal_entries' || c.table === 'journal_entry_lines')
&& ['update', 'insert', 'delete', 'upsert'].includes(c.method),
)
expect(journalWrites).toHaveLength(0)
expect(mock.supabase.rpc).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,509 @@
/**
* Link migrated invoices to the REGISTRATION voucher that booked them in the
* source system.
*
* A provider migration (Visma, Fortnox via the arcim-migration extension)
* imports the general ledger through SIE and the invoice registers through
* the provider API. Nothing connected the two: every migrated invoice landed
* with `registration_journal_entry_id` / `journal_entry_id` NULL although its
* booking verifikat exists in the GL, so the UI reads "Inget verifikat", the
* worklists count the invoice as unbooked, and an accrual company risks
* booking it a second time on payment.
*
* The provider names the booking voucher on the invoice ("A329"); the SIE
* import preserved that source ref on the entry it created. This module joins
* the two and writes the link, and nothing else:
*
* - it NEVER inserts, updates or deletes a journal entry or a line; the only
* writes are the two invoice-side foreign keys, and only from NULL;
* - a link is written only when the ref resolves to exactly ONE posted
* verifikat in the invoice's fiscal year, that verifikat is dated within a
* short corridor of the invoice date, its AP (244x) or AR (151x) net
* corroborates the invoice's SEK total to the öre, and no other invoice
* already points at it;
* - everything else stays NULL and is reported with its reason, so a
* kontantmetod invoice (the named voucher has no 244x/151x line), a split
* voucher, a credit note sharing its number or a duplicate ref is left for
* a human.
*
* The date corridor exists because source systems restart voucher numbering
* every fiscal year and the invoice date picks the year on our side: a
* December invoice that the source booked in January carries next year's
* number, which in the invoice-date year belongs to an unrelated voucher from
* the previous January. Requiring the verifikat to be dated near the invoice
* rejects that voucher even when a recurring amount happens to match; the
* cut-off invoice itself is reported `unresolved` rather than linked across
* the year boundary (a wrong link is räkenskapsinformation, a missing one is
* a worklist item).
*
* Foreign-currency invoices are corroborated on `total_sek`, which the
* migration derives from OUR rate index, while the source voucher was booked
* at the source system's rate. The two rarely agree to the öre, so such
* invoices normally land in `amountMismatch` with a reason that says so; the
* bucket is honest (the SEK amounts do differ), not a bookkeeping fault.
*
* Idempotent: re-running over already-linked invoices reports `alreadyLinked`
* and writes nothing. Payment vouchers are out of scope here; they are linked
* by lib/invoices/bulk-reconcile-supplier-vouchers.ts.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { ORE_TOLERANCE, roundOre } from '@/lib/money'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchLinesByEntryIds } from '@/lib/bookkeeping/entry-lines'
import {
buildVoucherIndex,
fetchFiscalPeriods,
fetchSourceRefVouchers,
periodIdForDate,
resolveDatedRef,
sourceRefKey,
voucherKey,
type FiscalPeriodRow,
type VoucherIndex,
type VoucherRow,
} from '@/lib/documents/voucher-ref-resolver'
import type { SourceVoucherRefDto } from '@/lib/providers/dto'
const log = createLogger('link-migrated-registration-vouchers')
export type MigratedInvoiceKind = 'supplier' | 'customer'
export interface MigratedInvoiceLinkInput {
/** `supplier_invoices.id` or `invoices.id`, depending on `kind`. */
invoiceId: string
kind: MigratedInvoiceKind
/** The booking voucher as the provider reported it; absent = nothing to resolve. */
sourceVoucher: SourceVoucherRefDto | null | undefined
/**
* True when the payload that carries the ref (Fortnox's detail form) was
* never fetched, so an absent `sourceVoucher` means "unknown", not "none".
* Reported as `refNotFetched` instead of `noRef`.
*/
refNotFetched?: boolean
/** Invoice date, ISO. Picks the fiscal year the ref is resolved in. */
invoiceDate: string
/** The invoice's SEK total. null = no SEK conversion was established. */
totalSek: number | null | undefined
/** Invoice currency; a non-SEK code explains an amount mismatch (see header). */
currencyCode?: string | null
/** Display only, carried into the report. */
invoiceNumber?: string | null
}
export type RegistrationLinkOutcome =
| 'linked'
| 'noRef'
| 'refNotFetched'
| 'unresolved'
| 'ambiguous'
| 'amountMismatch'
| 'alreadyLinked'
export interface RegistrationLinkReport {
invoiceId: string
kind: MigratedInvoiceKind
invoiceNumber: string | null
outcome: RegistrationLinkOutcome
/** Set for `linked` and `alreadyLinked`, when the entry is known. */
journalEntryId?: string
/** Machine-readable one-liner for logs and the migration report. */
reason: string
}
export interface RegistrationLinkCounts {
/** Inputs considered. */
scanned: number
linked: number
/** The provider reported no booking voucher for the invoice. */
noRef: number
/**
* The provider payload that carries the ref was never fetched (detail
* hydration ran out of budget or failed), so whether a voucher exists is
* unknown. A re-run of /reconcile with more budget can still link these.
*/
refNotFetched: number
/**
* The ref matched no posted verifikat in the invoice's fiscal year, the
* verifikat it matched is dated outside the corridor around the invoice
* date, or it carries no 244x/151x line (not a registration voucher).
*/
unresolved: number
/** More than one verifikat could be meant, or two invoices claim the same one. */
ambiguous: number
/** A verifikat resolved but its AP/AR net does not equal the invoice's SEK total. */
amountMismatch: number
/** The invoice, or the verifikat, already carried a registration link. */
alreadyLinked: number
}
export interface RegistrationLinkResult extends RegistrationLinkCounts {
reports: RegistrationLinkReport[]
}
export interface LinkMigratedRegistrationVouchersOptions {
supabase: SupabaseClient
companyId: string
invoices: MigratedInvoiceLinkInput[]
/** Resolve and corroborate but write nothing. Default false. */
dryRun?: boolean
}
/** BAS 2440-2449 Leverantörsskulder: the registration voucher credits it. */
const AP_ACCOUNT_PREFIX = '244'
/** BAS 1510-1519 Kundfordringar: the registration voucher debits it. */
const AR_ACCOUNT_PREFIX = '151'
/** PostgREST puts `.in()` lists in the URL, so id filters are chunked. */
const ID_CHUNK = 200
/**
* How far a registration voucher may be dated from its invoice. Both Visma
* and Fortnox default the booking date to the invoice date; a late-booked
* supplier invoice lands within a few weeks, and a pre-dated one (an invoice
* dated the 1st, received and booked in late the month before) a few days
* earlier. A voucher a year away is the previous year's same-numbered one.
*/
const ENTRY_DATE_DAYS_BEFORE = 14
const ENTRY_DATE_DAYS_AFTER = 90
const MS_PER_DAY = 86_400_000
/** Whole days from `from` to `to` (negative when `to` is earlier), or null for unparsable input. */
function daysBetween(from: string, to: string): number | null {
const a = Date.parse(from.slice(0, 10))
const b = Date.parse(to.slice(0, 10))
if (!Number.isFinite(a) || !Number.isFinite(b)) return null
return Math.round((b - a) / MS_PER_DAY)
}
function chunk<T>(items: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
return out
}
function emptyCounts(): RegistrationLinkCounts {
return {
scanned: 0,
linked: 0,
noRef: 0,
refNotFetched: 0,
unresolved: 0,
ambiguous: 0,
amountMismatch: 0,
alreadyLinked: 0,
}
}
type Resolution =
| { outcome: 'resolved'; entryId: string }
| { outcome: 'noRef' | 'refNotFetched' | 'unresolved' | 'ambiguous'; reason: string }
/**
* The verifikat must be dated near the invoice. See the module header: a
* same-numbered voucher a year away is the previous year's, and the amount
* check alone does not reliably tell them apart.
*/
function checkEntryDate(row: VoucherRow, input: MigratedInvoiceLinkInput): Resolution {
const days = daysBetween(input.invoiceDate, row.entry_date)
if (days === null) {
return { outcome: 'unresolved', reason: `verifikat date ${row.entry_date} or invoice date ${input.invoiceDate} is unreadable` }
}
if (days < -ENTRY_DATE_DAYS_BEFORE || days > ENTRY_DATE_DAYS_AFTER) {
const where = days < 0 ? `${-days} days before` : `${days} days after`
return {
outcome: 'unresolved',
reason: `verifikat is dated ${row.entry_date}, ${where} the invoice date ${input.invoiceDate}: outside the ${ENTRY_DATE_DAYS_BEFORE}/${ENTRY_DATE_DAYS_AFTER}-day corridor, likely another year's voucher`,
}
}
return { outcome: 'resolved', entryId: row.id }
}
/**
* One invoice's ref against the company's migrated verifikat, scoped to the
* fiscal year of the invoice date and to the date corridor around it.
* Series-less refs (a bare "329") search every series in that year and are
* accepted only on a single hit.
*/
function resolveInput(
index: VoucherIndex,
periods: FiscalPeriodRow[],
input: MigratedInvoiceLinkInput,
): Resolution {
const ref = input.sourceVoucher
if (!ref || !Number.isInteger(ref.number) || ref.number <= 0) {
if (input.refNotFetched) {
return { outcome: 'refNotFetched', reason: 'provider detail payload not fetched (hydration budget or failure): voucher unknown' }
}
return { outcome: 'noRef', reason: 'provider reported no booking voucher' }
}
const periodId = input.invoiceDate ? periodIdForDate(periods, input.invoiceDate) : null
if (!periodId) {
return { outcome: 'unresolved', reason: `no fiscal period covers invoice date ${input.invoiceDate || '(none)'}` }
}
if (ref.series === null) {
const hits = (index.byNumber.get(ref.number) ?? []).filter((v) => v.fiscal_period_id === periodId)
if (hits.length === 1) return checkEntryDate(hits[0], input)
if (hits.length === 0) {
return { outcome: 'unresolved', reason: `no migrated verifikat carries source number ${ref.number} in that fiscal year` }
}
return { outcome: 'ambiguous', reason: `source number ${ref.number} matches ${hits.length} series in that fiscal year` }
}
const entryId = resolveDatedRef(index, periods, { series: ref.series, number: ref.number, date: input.invoiceDate })
if (entryId) {
// resolveDatedRef hands back the id; the row (with its entry_date) sits
// in the period-agnostic index under the same source ref.
const row = (index.bySourceRef.get(sourceRefKey(ref.series, ref.number)) ?? []).find((v) => v.id === entryId)
if (!row) {
return { outcome: 'unresolved', reason: `source ref ${ref.series}${ref.number} resolved to an entry the index does not carry` }
}
return checkEntryDate(row, input)
}
if (index.ambiguousPeriodKeys.has(voucherKey(periodId, ref.series, ref.number))) {
return { outcome: 'ambiguous', reason: `source ref ${ref.series}${ref.number} is carried by more than one verifikat in that fiscal year` }
}
return { outcome: 'unresolved', reason: `no migrated verifikat carries source ref ${ref.series}${ref.number} in that fiscal year` }
}
interface EntryRow {
id: string
status: string
}
interface LineRow {
id: string
journal_entry_id: string
account_number: string
debit_amount: number | null
credit_amount: number | null
}
/**
* Link every input whose ref resolves to exactly one posted, unclaimed,
* amount-corroborated verifikat. See the module docstring for the guarantees.
*/
export async function linkMigratedRegistrationVouchers(
options: LinkMigratedRegistrationVouchersOptions,
): Promise<RegistrationLinkResult> {
const { supabase, companyId, invoices, dryRun = false } = options
const counts = emptyCounts()
const reports: RegistrationLinkReport[] = []
const report = (input: MigratedInvoiceLinkInput, outcome: RegistrationLinkOutcome, reason: string, journalEntryId?: string) => {
counts[outcome]++
reports.push({
invoiceId: input.invoiceId,
kind: input.kind,
invoiceNumber: input.invoiceNumber ?? null,
outcome,
reason,
...(journalEntryId ? { journalEntryId } : {}),
})
}
counts.scanned = invoices.length
if (invoices.length === 0) return { ...counts, reports }
// 1. The company's migrated verifikat and fiscal years, indexed once.
const [vouchers, periods] = await Promise.all([
fetchSourceRefVouchers(supabase, companyId),
fetchFiscalPeriods(supabase, companyId),
])
const index = buildVoucherIndex(vouchers)
// 2. Resolve refs in memory. Two inputs landing on one verifikat is a
// contest neither side can win without guessing: both stay NULL.
const resolved: { input: MigratedInvoiceLinkInput; entryId: string }[] = []
const claimants = new Map<string, MigratedInvoiceLinkInput[]>()
for (const input of invoices) {
const resolution = resolveInput(index, periods, input)
if (resolution.outcome !== 'resolved') {
report(input, resolution.outcome, resolution.reason)
continue
}
resolved.push({ input, entryId: resolution.entryId })
const list = claimants.get(resolution.entryId)
if (list) list.push(input)
else claimants.set(resolution.entryId, [input])
}
if (resolved.length === 0) return { ...counts, reports }
const entryIds = [...claimants.keys()]
// 3. Corroboration reads: entry status, the AP/AR lines, and the invoices
// that already point at these entries. All company-scoped.
const entriesById = new Map<string, EntryRow>()
for (const ids of chunk(entryIds, ID_CHUNK)) {
const rows = await fetchAllRows<EntryRow>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id, status')
.eq('company_id', companyId)
.in('id', ids)
.order('id', { ascending: true })
.range(from, to),
)
for (const row of rows) entriesById.set(row.id, row)
}
const lines = await fetchLinesByEntryIds<LineRow>(
supabase,
entryIds,
'journal_entry_id, account_number, debit_amount, credit_amount',
)
const apNetCreditByEntry = new Map<string, number>()
const arNetDebitByEntry = new Map<string, number>()
for (const line of lines) {
const account = String(line.account_number ?? '')
const debit = Number(line.debit_amount ?? 0)
const credit = Number(line.credit_amount ?? 0)
if (account.startsWith(AP_ACCOUNT_PREFIX)) {
apNetCreditByEntry.set(line.journal_entry_id, roundOre((apNetCreditByEntry.get(line.journal_entry_id) ?? 0) + credit - debit))
}
if (account.startsWith(AR_ACCOUNT_PREFIX)) {
arNetDebitByEntry.set(line.journal_entry_id, roundOre((arNetDebitByEntry.get(line.journal_entry_id) ?? 0) + debit - credit))
}
}
// Invoice ids already referencing each entry, on either register.
const referencedBy = new Map<string, string[]>()
const noteReference = (entryId: string | null, invoiceId: string) => {
if (!entryId) return
const list = referencedBy.get(entryId)
if (list) list.push(invoiceId)
else referencedBy.set(entryId, [invoiceId])
}
for (const ids of chunk(entryIds, ID_CHUNK)) {
const supplierRows = await fetchAllRows<{ id: string; registration_journal_entry_id: string | null }>(({ from, to }) =>
supabase
.from('supplier_invoices')
.select('id, registration_journal_entry_id')
.eq('company_id', companyId)
.in('registration_journal_entry_id', ids)
.order('id', { ascending: true })
.range(from, to),
)
for (const row of supplierRows) noteReference(row.registration_journal_entry_id, row.id)
const customerRows = await fetchAllRows<{ id: string; journal_entry_id: string | null }>(({ from, to }) =>
supabase
.from('invoices')
.select('id, journal_entry_id')
.eq('company_id', companyId)
.in('journal_entry_id', ids)
.order('id', { ascending: true })
.range(from, to),
)
for (const row of customerRows) noteReference(row.journal_entry_id, row.id)
}
// 4. Decide and write, one invoice at a time, from NULL only.
for (const { input, entryId } of resolved) {
const contest = claimants.get(entryId) ?? []
if (contest.length > 1) {
report(input, 'ambiguous', `${contest.length} migrated invoices resolve to the same verifikat`)
continue
}
const entry = entriesById.get(entryId)
if (!entry) {
// Resolved from the company's own index, so this is a vanished row or a
// scope mismatch. Either way there is nothing safe to link.
report(input, 'unresolved', 'verifikat not found in this company')
continue
}
if (entry.status !== 'posted') {
report(input, 'unresolved', `verifikat is ${entry.status}, not posted`)
continue
}
const holders = referencedBy.get(entryId) ?? []
if (holders.includes(input.invoiceId)) {
report(input, 'alreadyLinked', 'invoice already links this verifikat', entryId)
continue
}
if (holders.length > 0) {
report(input, 'alreadyLinked', 'verifikat is already the registration voucher of another invoice', entryId)
continue
}
if (typeof input.totalSek !== 'number' || !Number.isFinite(input.totalSek)) {
report(input, 'amountMismatch', 'invoice has no SEK total to corroborate against')
continue
}
const expected = roundOre(input.totalSek)
const booked = input.kind === 'supplier'
? apNetCreditByEntry.get(entryId)
: arNetDebitByEntry.get(entryId)
const side = input.kind === 'supplier' ? 'net credit on 244x' : 'net debit on 151x'
if (booked === undefined) {
// No AP/AR line at all: the provider named a voucher, but it is not a
// registration voucher for this kind of invoice. A kontantmetod company
// books on payment (Dr cost / Cr bank) and may still name that voucher.
report(input, 'unresolved', `verifikat has no ${input.kind === 'supplier' ? '244x' : '151x'} line: not a registration voucher (kontantmetod or payment voucher)`)
continue
}
if (Math.abs(booked - expected) > ORE_TOLERANCE) {
const currency = (input.currencyCode ?? 'SEK').toUpperCase()
report(
input,
'amountMismatch',
currency !== 'SEK'
? `${side} is ${booked} but the invoice total is ${expected} SEK; the invoice is in ${currency}, its SEK total uses our rate index and the source booked at its own rate, so the amounts are not corroborated`
: `${side} is ${booked} but the invoice total is ${expected} SEK`,
)
continue
}
if (dryRun) {
report(input, 'linked', 'would link (dry run)', entryId)
continue
}
// `.is(null)` makes the write a no-op when the row gained a link since it
// was read; `.select('id')` is how that no-op becomes visible.
const { data, error } = input.kind === 'supplier'
? await supabase
.from('supplier_invoices')
.update({ registration_journal_entry_id: entryId })
.eq('id', input.invoiceId)
.eq('company_id', companyId)
.is('registration_journal_entry_id', null)
.select('id')
: await supabase
.from('invoices')
.update({ journal_entry_id: entryId })
.eq('id', input.invoiceId)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.select('id')
if (error) {
throw new Error(`Failed to link ${input.kind} invoice ${input.invoiceId} to verifikat ${entryId}: ${error.message}`)
}
if (!data || data.length === 0) {
report(input, 'alreadyLinked', 'invoice already carried a registration link', entryId)
continue
}
report(input, 'linked', 'registration voucher linked', entryId)
}
log.info('registration voucher linking complete', {
companyId,
dryRun,
scanned: counts.scanned,
linked: counts.linked,
noRef: counts.noRef,
refNotFetched: counts.refNotFetched,
unresolved: counts.unresolved,
ambiguous: counts.ambiguous,
amountMismatch: counts.amountMismatch,
alreadyLinked: counts.alreadyLinked,
})
return { ...counts, reports }
}
@@ -62,9 +62,10 @@ describe('fetchSalesInvoicesHydrated (fortnox)', () => {
it('fills in the VAT the list payload omitted', async () => {
stubFetch((url) => url.includes('/invoices/4') ? json(detailFor(4, 1250)) : json(listResponse([OPEN])));
const { invoices, hydration } = await fetchSalesInvoicesHydrated('fortnox', 'token');
const { invoices, hydration, unhydratedIds } = await fetchSalesInvoicesHydrated('fortnox', 'token');
expect(hydration).toMatchObject({ needed: 1, hydrated: 1, failed: 0, skippedForBudget: 0 });
expect(unhydratedIds.size).toBe(0);
expect(invoices[0]?.taxTotal?.taxAmount.value).toBe(250);
expect(invoices[0]?.legalMonetaryTotal.lineExtensionAmount?.value).toBe(1000);
expect(invoices[0]?.lines).toHaveLength(1);
@@ -100,11 +101,13 @@ describe('fetchSalesInvoicesHydrated (fortnox)', () => {
it('reports what the budget could not reach instead of looking complete', async () => {
stubFetch(() => json(listResponse([OPEN, PAID])));
const { invoices, hydration } = await fetchSalesInvoicesHydrated('fortnox', 'token', undefined, 0);
const { invoices, hydration, unhydratedIds } = await fetchSalesInvoicesHydrated('fortnox', 'token', undefined, 0);
expect(hydration).toMatchObject({ needed: 2, hydrated: 0, skippedForBudget: 2 });
// The invoices themselves are still returned, unhydrated.
// The invoices themselves are still returned, unhydrated, and named as
// such: a detail-only field (Fortnox's voucher ref) is unknown for these.
expect(invoices).toHaveLength(2);
expect([...unhydratedIds].sort()).toEqual(['4', '5']);
expect(requested.filter((u) => /\/invoices\/\d/.test(u))).toHaveLength(0);
});
@@ -152,11 +155,12 @@ describe('fetchSalesInvoicesHydrated (fortnox)', () => {
? new Response('boom', { status: 404 })
: json(listResponse([OPEN])));
const { invoices, hydration } = await fetchSalesInvoicesHydrated('fortnox', 'token');
const { invoices, hydration, unhydratedIds } = await fetchSalesInvoicesHydrated('fortnox', 'token');
expect(hydration).toMatchObject({ needed: 1, hydrated: 0, failed: 1 });
expect(invoices).toHaveLength(1);
expect(invoices[0]?.legalMonetaryTotal.payableAmount.value).toBe(1250);
expect(unhydratedIds.has('4')).toBe(true);
});
});
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import { parseSourceVoucherRef, sourceVoucherFromParts } from '../source-voucher'
/**
* The parser feeds the registration-voucher link (#1463). A false positive
* here becomes a wrong verifikat link on a migrated invoice, so garbage must
* come out as null, never as a best-effort number.
*/
describe('parseSourceVoucherRef', () => {
it('reads the Visma spelling with the series glued to the number', () => {
expect(parseSourceVoucherRef('A329')).toEqual({ series: 'A', number: 329 })
})
it('accepts a space or hyphen between series and number, and lowercase series', () => {
expect(parseSourceVoucherRef('A 329')).toEqual({ series: 'A', number: 329 })
expect(parseSourceVoucherRef('a-329')).toEqual({ series: 'A', number: 329 })
expect(parseSourceVoucherRef(' B12 ')).toEqual({ series: 'B', number: 12 })
})
it('reads a bare number as series-less', () => {
expect(parseSourceVoucherRef('329')).toEqual({ series: null, number: 329 })
expect(parseSourceVoucherRef(329)).toEqual({ series: null, number: 329 })
})
it('returns null on anything it cannot read with certainty', () => {
expect(parseSourceVoucherRef(undefined)).toBeNull()
expect(parseSourceVoucherRef(null)).toBeNull()
expect(parseSourceVoucherRef('')).toBeNull()
expect(parseSourceVoucherRef(' ')).toBeNull()
expect(parseSourceVoucherRef('A')).toBeNull()
expect(parseSourceVoucherRef('A329B')).toBeNull()
expect(parseSourceVoucherRef('12.5')).toBeNull()
expect(parseSourceVoucherRef('0')).toBeNull()
expect(parseSourceVoucherRef('-5')).toBeNull()
expect(parseSourceVoucherRef('Verifikation A329')).toBeNull()
expect(parseSourceVoucherRef(3.5)).toBeNull()
expect(parseSourceVoucherRef({ VoucherNumber: 'A1' })).toBeNull()
})
})
describe('sourceVoucherFromParts', () => {
it('reads the Fortnox split form', () => {
expect(sourceVoucherFromParts('A', 329)).toEqual({ series: 'A', number: 329 })
expect(sourceVoucherFromParts('a', '329')).toEqual({ series: 'A', number: 329 })
})
it('tolerates a missing series but not a missing or zero number', () => {
expect(sourceVoucherFromParts(undefined, 7)).toEqual({ series: null, number: 7 })
expect(sourceVoucherFromParts('', 7)).toEqual({ series: null, number: 7 })
expect(sourceVoucherFromParts('A', 0)).toBeNull()
expect(sourceVoucherFromParts('A', undefined)).toBeNull()
expect(sourceVoucherFromParts('A', null)).toBeNull()
expect(sourceVoucherFromParts('A', 'x')).toBeNull()
expect(sourceVoucherFromParts('A', 1.5)).toBeNull()
})
})
+14
View File
@@ -101,6 +101,18 @@ export interface PaginatedResponse<T> {
hasMore: boolean;
}
/**
* The verifikat that booked the invoice in the SOURCE system, as the provider
* reports it ("A329"). Optional: only providers that expose it (Visma
* eAccounting, Fortnox) set it, and only on booked invoices. The migration
* uses it to link the imported invoice to the SIE-imported registration
* voucher; see lib/providers/source-voucher.ts for the parsing rules.
*/
export interface SourceVoucherRefDto {
series: string | null;
number: number;
}
// ============================================
// Sales Invoice
// ============================================
@@ -169,6 +181,7 @@ export interface SalesInvoiceDto {
buyerReference?: string;
orderReference?: string;
financialDimensions?: FinancialDimensionRef[];
sourceVoucher?: SourceVoucherRefDto;
createdAt?: string;
updatedAt?: string;
_raw?: Record<string, unknown>;
@@ -213,6 +226,7 @@ export interface SupplierInvoiceDto {
note?: string;
ocrNumber?: string;
financialDimensions?: FinancialDimensionRef[];
sourceVoucher?: SourceVoucherRefDto;
createdAt?: string;
updatedAt?: string;
_raw?: Record<string, unknown>;
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest'
import { mapFortnoxToSupplierInvoice, mapFortnoxToSalesInvoice } from '../mapper'
/**
* Fortnox names the booking voucher on the detail form of a booked invoice as
* VoucherSeries + VoucherNumber (+ VoucherYear). The migration links the
* imported invoice to the SIE-imported verifikat through the pair (#1463).
* VoucherYear is deliberately NOT read: the invoice date resolves the fiscal
* year on our side.
*/
function salesRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
DocumentNumber: '200',
Total: 1000,
Net: 800,
TotalVAT: 200,
InvoiceDate: '2026-01-10',
DueDate: '2026-02-10',
CustomerName: 'Kund AB',
Sent: true,
Booked: true,
...over,
}
}
function supplierRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
GivenNumber: '100',
Total: 1000,
Net: 800,
TotalVAT: 200,
InvoiceDate: '2026-01-10',
DueDate: '2026-02-10',
SupplierName: 'Leverantör AB',
Booked: true,
...over,
}
}
describe('mapFortnoxToSalesInvoice: sourceVoucher', () => {
it('reads VoucherSeries + VoucherNumber and ignores VoucherYear', () => {
const dto = mapFortnoxToSalesInvoice(salesRaw({ VoucherSeries: 'A', VoucherNumber: 329, VoucherYear: 3 }))
expect(dto.sourceVoucher).toEqual({ series: 'A', number: 329 })
})
it('leaves sourceVoucher undefined on the list form (no voucher fields)', () => {
expect(mapFortnoxToSalesInvoice(salesRaw()).sourceVoucher).toBeUndefined()
})
it('leaves sourceVoucher undefined for an unbooked invoice (VoucherNumber 0)', () => {
const dto = mapFortnoxToSalesInvoice(salesRaw({ Booked: false, VoucherSeries: '', VoucherNumber: 0 }))
expect(dto.sourceVoucher).toBeUndefined()
})
})
describe('mapFortnoxToSupplierInvoice: sourceVoucher', () => {
it('reads VoucherSeries + VoucherNumber', () => {
const dto = mapFortnoxToSupplierInvoice(supplierRaw({ VoucherSeries: 'B', VoucherNumber: '41', VoucherYear: 3 }))
expect(dto.sourceVoucher).toEqual({ series: 'B', number: 41 })
})
it('leaves sourceVoucher undefined when absent or malformed', () => {
expect(mapFortnoxToSupplierInvoice(supplierRaw()).sourceVoucher).toBeUndefined()
expect(mapFortnoxToSupplierInvoice(supplierRaw({ VoucherSeries: 'B', VoucherNumber: 'x' })).sourceVoucher).toBeUndefined()
})
})
+6
View File
@@ -10,6 +10,7 @@ import type {
AmountType, PartyDto,
} from '../dto';
import { readNumber, resolveVatTriple, lineVatFromPercent } from '../amounts';
import { sourceVoucherFromParts } from '../source-voucher';
/**
* Fortnox splits its invoice payloads in two. `GET /3/invoices` answers with
@@ -187,6 +188,10 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
note: raw['Remarks'] as string | undefined,
buyerReference: raw['YourReference'] as string | undefined,
orderReference: raw['YourOrderNumber'] as string | undefined,
// The booking voucher, present on the detail form of a booked invoice.
// `VoucherYear` is deliberately not read: the invoice date resolves the
// fiscal year on our side, and the source's year id is not ours.
sourceVoucher: sourceVoucherFromParts(raw['VoucherSeries'], raw['VoucherNumber']) ?? undefined,
updatedAt: raw['@LastModified'] as string | undefined,
_raw: raw,
};
@@ -258,6 +263,7 @@ export function mapFortnoxToSupplierInvoice(raw: Record<string, unknown>): Suppl
legalMonetaryTotal,
paymentStatus,
ocrNumber: raw['OCR'] as string | undefined,
sourceVoucher: sourceVoucherFromParts(raw['VoucherSeries'], raw['VoucherNumber']) ?? undefined,
updatedAt: raw['@LastModified'] as string | undefined,
_raw: raw,
};
+35 -10
View File
@@ -395,6 +395,18 @@ const EMPTY_HYDRATION_REPORT: HydrationReport = {
needed: 0, hydrated: 0, failed: 0, skippedForBudget: 0,
};
/** A register with its detail payloads merged in, plus what hydration missed. */
export interface HydratedInvoices<T extends SalesInvoiceDto | SupplierInvoiceDto> {
invoices: T[];
hydration: HydrationReport;
/**
* Ids (`dto.id`) of invoices that needed their detail form and did not get
* it. Fields that only the detail form carries are UNKNOWN for these, not
* absent: the migration must not report them as "the provider had none".
*/
unhydratedIds: Set<string>;
}
/**
* Default wall-clock ceiling for one hydration pass.
*
@@ -562,6 +574,12 @@ async function mapWithConcurrency<T>(
* Returns a NEW array in the original order; entries that were not hydrated
* (already complete, out of budget, or the fetch failed) are the originals,
* so the caller never ends up with fewer invoices than it passed in.
*
* `unhydratedIds` names the invoices that NEEDED a detail form and did not
* get one (budget, abort, or a failed fetch). A consumer that reads a field
* only the detail form carries (Fortnox's booking voucher, for instance) can
* tell "the provider reported none" from "we never asked" only through this
* set; the counts in the report do not say which invoices they were.
*/
async function hydrateInvoices<T extends SalesInvoiceDto | SupplierInvoiceDto>(
items: T[],
@@ -570,14 +588,18 @@ async function hydrateInvoices<T extends SalesInvoiceDto | SupplierInvoiceDto>(
mapper: ((raw: Record<string, unknown>) => unknown) | null,
label: string,
budgetMs: number,
): Promise<{ items: T[]; report: HydrationReport }> {
if (!fetchDetail || !mapper) return { items, report: { ...EMPTY_HYDRATION_REPORT } };
): Promise<{ items: T[]; report: HydrationReport; unhydratedIds: Set<string> }> {
if (!fetchDetail || !mapper) {
return { items, report: { ...EMPTY_HYDRATION_REPORT }, unhydratedIds: new Set() };
}
const pending = items
.map((dto, index) => ({ dto, index }))
.filter(({ dto }) => needsDetail(dto) && dto.id);
if (pending.length === 0) return { items, report: { ...EMPTY_HYDRATION_REPORT } };
if (pending.length === 0) {
return { items, report: { ...EMPTY_HYDRATION_REPORT }, unhydratedIds: new Set() };
}
// Unpaid invoices are the ones a later payment match or credit note will
// book, so they get the budget first.
@@ -585,6 +607,8 @@ async function hydrateInvoices<T extends SalesInvoiceDto | SupplierInvoiceDto>(
const hydrated = [...items];
const report: HydrationReport = { ...EMPTY_HYDRATION_REPORT, needed: pending.length };
// Every pending id starts out unhydrated and is removed on success.
const unhydratedIds = new Set<string>(pending.map(({ dto }) => dto.id));
const deadline = Date.now() + budgetMs;
let aborted: 'auth' | 'budget' | null = null;
@@ -615,6 +639,7 @@ async function hydrateInvoices<T extends SalesInvoiceDto | SupplierInvoiceDto>(
}
hydrated[index] = mapper(raw) as T;
report.hydrated++;
unhydratedIds.delete(dto.id);
} catch (err) {
report.failed++;
@@ -653,7 +678,7 @@ async function hydrateInvoices<T extends SalesInvoiceDto | SupplierInvoiceDto>(
+ (aborted ? ` (stopped early: ${aborted})` : ''),
);
return { items: hydrated, report };
return { items: hydrated, report, unhydratedIds };
}
/** Thrown when a detail fetch is still outstanding at the budget deadline. */
@@ -702,10 +727,10 @@ export async function fetchSalesInvoicesHydrated(
accessToken: string,
providerCompanyId?: string,
budgetMs: number = DEFAULT_HYDRATION_BUDGET_MS,
): Promise<{ invoices: SalesInvoiceDto[]; hydration: HydrationReport }> {
): Promise<HydratedInvoices<SalesInvoiceDto>> {
const invoices = await fetchSalesInvoicesDirect(provider, accessToken, providerCompanyId);
const { items, report } = await hydrateInvoices<SalesInvoiceDto>(
const { items, report, unhydratedIds } = await hydrateInvoices<SalesInvoiceDto>(
invoices,
salesInvoiceNeedsDetail,
detailFetcher(provider, ResourceType.SalesInvoices, accessToken, providerCompanyId),
@@ -714,7 +739,7 @@ export async function fetchSalesInvoicesHydrated(
budgetMs,
);
return { invoices: items, hydration: report };
return { invoices: items, hydration: report, unhydratedIds };
}
/** Supplier invoices with their detail payloads merged in where needed. */
@@ -723,10 +748,10 @@ export async function fetchSupplierInvoicesHydrated(
accessToken: string,
providerCompanyId?: string,
budgetMs: number = DEFAULT_HYDRATION_BUDGET_MS,
): Promise<{ invoices: SupplierInvoiceDto[]; hydration: HydrationReport }> {
): Promise<HydratedInvoices<SupplierInvoiceDto>> {
const invoices = await fetchSupplierInvoicesDirect(provider, accessToken, providerCompanyId);
const { items, report } = await hydrateInvoices<SupplierInvoiceDto>(
const { items, report, unhydratedIds } = await hydrateInvoices<SupplierInvoiceDto>(
invoices,
supplierInvoiceNeedsDetail,
detailFetcher(provider, ResourceType.SupplierInvoices, accessToken, providerCompanyId),
@@ -735,5 +760,5 @@ export async function fetchSupplierInvoicesHydrated(
budgetMs,
);
return { invoices: items, hydration: report };
return { invoices: items, hydration: report, unhydratedIds };
}
+59
View File
@@ -0,0 +1,59 @@
/**
* The voucher reference a provider attaches to an invoice, as written in the
* SOURCE system ("A329"). It is the only safe join key back to the verifikat
* the SIE import created for that booking: the importer renumbers per series
* but preserves the source pair on `journal_entries.source_voucher_series` /
* `source_voucher_number` (see lib/documents/voucher-ref-resolver.ts).
*
* Providers spell the reference differently: Visma eAccounting puts one
* string on the invoice (`VoucherNumber`, "A329" or a bare "329"), Fortnox
* splits it into `VoucherSeries` + `VoucherNumber`. Both normalise to the
* same shape here. A reference that cannot be read with certainty becomes
* null rather than a guess: a wrong link attaches an invoice to somebody
* else's verifikat and is räkenskapsinformation once written.
*/
import type { SourceVoucherRefDto } from './dto'
export type { SourceVoucherRefDto }
/**
* "A329", "A 329", "A-329", "a329" and a bare "329" all parse; anything else
* (empty, decimals, prose, a number with no digits) yields null.
*/
const REF_PATTERN = /^(?:([A-Za-zÅÄÖåäö]{1,4})[\s-]*)?(\d{1,9})$/
export function parseSourceVoucherRef(value: unknown): SourceVoucherRefDto | null {
if (typeof value === 'number') {
return Number.isInteger(value) && value > 0 ? { series: null, number: value } : null
}
if (typeof value !== 'string') return null
const match = REF_PATTERN.exec(value.trim())
if (!match) return null
const number = Number.parseInt(match[2], 10)
if (!Number.isFinite(number) || number <= 0) return null
return { series: match[1] ? match[1].toUpperCase() : null, number }
}
/**
* The split form (Fortnox): a series string next to a numeric voucher number.
* The series is optional (some payloads omit it on unbooked invoices); the
* number is not. A number of 0 means "not booked" in Fortnox and yields null.
*/
export function sourceVoucherFromParts(series: unknown, number: unknown): SourceVoucherRefDto | null {
const parsedNumber =
typeof number === 'number'
? number
: typeof number === 'string' && /^\d{1,9}$/.test(number.trim())
? Number.parseInt(number.trim(), 10)
: NaN
if (!Number.isInteger(parsedNumber) || parsedNumber <= 0) return null
const parsedSeries =
typeof series === 'string' && series.trim() ? series.trim().toUpperCase() : null
return { series: parsedSeries, number: parsedNumber }
}
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import { mapVismaToSalesInvoice, mapVismaToSupplierInvoice } from '../mapper'
/**
* eAccounting names the booking voucher on both invoice APIs as
* `VoucherNumber` ("A329"). The migration links the imported invoice to the
* SIE-imported verifikat through it (#1463), so the mapper must carry it as
* a parsed ref, and must carry NOTHING when the field is absent or unreadable.
*/
function salesRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
Id: 's1',
InvoiceNumber: '10060',
InvoiceDate: '2026-07-24',
DueDate: '2026-08-10',
CurrencyCode: 'SEK',
TotalAmount: 75000,
TotalVatAmount: 15000,
InvoiceCustomerName: 'Kund AB',
Rows: [],
...over,
}
}
function supplierRaw(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
Id: 'b1',
InvoiceNumber: '903127919426',
InvoiceDate: '2026-07-31',
DueDate: '2026-08-30',
CurrencyCode: 'SEK',
TotalAmount: 1250,
TotalVatAmount: 250,
SupplierName: 'PostNord Sverige AB',
Rows: [],
...over,
}
}
describe('mapVismaToSalesInvoice: sourceVoucher', () => {
it('parses VoucherNumber into series + number', () => {
const dto = mapVismaToSalesInvoice(salesRaw({ VoucherNumber: 'A329' }))
expect(dto.sourceVoucher).toEqual({ series: 'A', number: 329 })
})
it('keeps a bare number as series-less', () => {
const dto = mapVismaToSalesInvoice(salesRaw({ VoucherNumber: '329' }))
expect(dto.sourceVoucher).toEqual({ series: null, number: 329 })
})
it('leaves sourceVoucher undefined when the field is absent', () => {
const dto = mapVismaToSalesInvoice(salesRaw())
expect(dto.sourceVoucher).toBeUndefined()
})
it('leaves sourceVoucher undefined when the field is malformed', () => {
expect(mapVismaToSalesInvoice(salesRaw({ VoucherNumber: '' })).sourceVoucher).toBeUndefined()
expect(mapVismaToSalesInvoice(salesRaw({ VoucherNumber: 'n/a' })).sourceVoucher).toBeUndefined()
expect(mapVismaToSalesInvoice(salesRaw({ VoucherNumber: null })).sourceVoucher).toBeUndefined()
})
})
describe('mapVismaToSupplierInvoice: sourceVoucher', () => {
it('parses VoucherNumber into series + number', () => {
const dto = mapVismaToSupplierInvoice(supplierRaw({ VoucherNumber: 'B 41' }))
expect(dto.sourceVoucher).toEqual({ series: 'B', number: 41 })
})
it('leaves sourceVoucher undefined when absent or malformed', () => {
expect(mapVismaToSupplierInvoice(supplierRaw()).sourceVoucher).toBeUndefined()
expect(mapVismaToSupplierInvoice(supplierRaw({ VoucherNumber: 'A' })).sourceVoucher).toBeUndefined()
})
})
+5
View File
@@ -14,6 +14,7 @@ import {
lineVatFromPercent,
multiplyIfBothPresent,
} from '../amounts';
import { parseSourceVoucherRef } from '../source-voucher';
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
return { value: value ?? 0, currencyCode: currency };
@@ -192,6 +193,9 @@ export function mapVismaToSalesInvoice(raw: Record<string, unknown>): SalesInvoi
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
// eAccounting names the booking voucher on the invoice itself
// (`VoucherNumber`, "A329"). Kept only when it parses cleanly.
sourceVoucher: parseSourceVoucherRef(raw['VoucherNumber']) ?? undefined,
createdAt: raw['CreatedUtc'] as string | undefined,
updatedAt: raw['ModifiedUtc'] as string | undefined,
_raw: raw,
@@ -263,6 +267,7 @@ export function mapVismaToSupplierInvoice(raw: Record<string, unknown>): Supplie
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
sourceVoucher: parseSourceVoucherRef(raw['VoucherNumber']) ?? undefined,
updatedAt: raw['ModifiedUtc'] as string | undefined,
_raw: raw,
};