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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user