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
+2
View File
@@ -1338,6 +1338,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review).
[2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring.
[2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card.
[2026-08-29] Migrated invoices are linked to their REGISTRATION voucher only (PR A of #1463; payment vouchers stay with bulk-reconcile-supplier-vouchers and a later PR): the provider names the booking voucher on the invoice (Visma `VoucherNumber` "A329", Fortnox `VoucherSeries` + `VoucherNumber`; `VoucherYear` is ignored, the invoice date picks the fiscal year), the SIE import preserved that source ref on `journal_entries.source_voucher_*`, and lib/invoices/link-migrated-registration-vouchers.ts joins the two through the existing voucher-ref-resolver. A link is written only when the ref resolves to exactly ONE posted verifikat in the invoice's fiscal year, its net credit on 244x (supplier) or net debit on 151x (customer) equals `total_sek` within 0.005, and no invoice already references it; a verifikat with no 244x/151x line at all (kontantmetod books on payment, or the provider named a payment voucher) is reported `unresolved`, never linked, and a credit note whose sign does not corroborate stays unlinked rather than being matched on absolute value. The only writes are `supplier_invoices.registration_journal_entry_id` / `invoices.journal_entry_id`, from NULL, company-scoped; journal tables are never touched. Nothing stores the provider ref on the invoice row, so the /reconcile re-run (`{ consentId }`) re-fetches both registers from the provider and joins on invoice number (sales; UNIQUE per company) or supplier invoice number + date (unique on both sides) before handing the pairs to the same linker: a stored ref would have been cheaper to re-run but is a schema change this PR deliberately avoids.
[2026-08-30] Registration-voucher linker rejects a verifikat dated outside 14 days before / 90 days after the invoice date, and does NOT fall back to the following fiscal year: source systems restart numbering per year and the invoice date picks the year on our side, so a December invoice booked in January resolves to the previous January's same-numbered voucher, which a recurring amount can corroborate by coincidence. The corridor closes that wrong link cheaply (VoucherRow already carries entry_date); the cut-off invoice itself stays NULL (`unresolved`) rather than being resolved in the next year, because the same coincidence would then apply to Q4 invoices whose true voucher the SIE import skipped. Fortnox invoices whose detail form was never hydrated are reported `refNotFetched`, not `noRef`: the list form never carries VoucherSeries/VoucherNumber, so "provider reported no voucher" would be untrue. Foreign-currency invoices stay in `amountMismatch` with a reason naming the rate difference (our Riksbanken `total_sek` vs the source's own rate) instead of a rate-tolerant match: a tolerant match would need the source rate, which SIE4 does not carry. The /reconcile relink joins BOTH registers on invoice number AND invoice date and skips sales drafts: it reads every NULL-link row in the company, so native invoices sit next to migrated ones and a number alone could hand the linker a native invoice that reuses a provider number. The consent is validated (company-scoped, getConsent) BEFORE the payment reconcile writes, so a wrong consentId is a clean 404 instead of a 500 after a persisted write, and a relink failure is returned as `registrationLinksError` beside the payment result rather than discarding it. The two hardcoded Swedish progress labels follow the five sibling steps and are left for a joint i18n follow-up; the relink stays API-only (no workspace button) because UI changes need a visual sign-off.
[2026-08-29] get_vat_ruta_source_lines ACL restored in a NEW migration (20260829090500) rather than by editing 20260828172003: that file DROPped the 9-arg overload and CREATEd the 11-arg one without restating REVOKE/GRANT, and DROP FUNCTION discards the ACL, so the new signature silently fell back to EXECUTE for PUBLIC (anon included); the migration is already applied on prod, so a follow-up file is the only compliant path. Rule going forward: every DROP + CREATE of an RPC must restate its REVOKE ALL FROM PUBLIC, anon / GRANT EXECUTE TO authenticated, service_role, and tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts now pins it with has_function_privilege (anon false, authenticated and service_role true, exactly one overload).
[2026-08-29] PR #1756 replacement (rebind on PSD2 remap, amends the 2026-07-09 #916 entry): when upsertFromPsd2 resolves a duplicate row for the same connection+uid, the duplicate's MOVABLE transactions (unbooked, unmatched, not anchored via transaction_voucher_links or a payment row: the #1570 single-row move gate) are rebound onto the promoted row BEFORE the duplicate is resolved, so categorize/booking proposes the ledger the user just mapped instead of the overflow slot; a duplicate that still holds booked or anchored rows is demoted to manual as before and never deleted (their vouchers carry the old 19xx line, and the #1643 orphan guards handle the released twin). The contributor's unconditional rebind-all-then-delete was narrowed for that reason.
[2026-08-29] Database errors now keep their SQLSTATE: new lib/errors/db-error.ts (dbError/errorCauseTag), applied at the 54 `throw new Error(\`Database error: ${err.message}\`)` sites in the MCP server AND, far more importantly, at lib/supabase/fetch-all.ts:74 where `throw new Error(error.message)` was the single highest-traffic strip point in the codebase (31 callers; every paginated read). isTransientFailure() checks the driver code FIRST and 57014 (statement timeout) is already in TRANSIENT_SQLSTATES, so discarding it turned a retryable timeout into UNKNOWN_ERROR ("Något gick fel. Försök igen."), which an agent cannot dispatch on. Traced end to end: gnubok_query_journal -> fetchEntryLines -> fetchAllRows (code stripped here) -> the tool's own sanitizeDbError, which ALREADY had a correct TRANSIENT_ERROR branch with a "retry or narrow with date_from/date_to" hint that could never fire because getStructuredError saw an anonymous Error. Measured on prod over 60 days with bot actors excluded: 1 024 real-agent failures, 645 UNKNOWN_ERROR across 60 actors and 57 companies; query_journal failed 164 times at p50 8 110 ms while every other failing tool sat at 1-315 ms; 82 retry streaks, 462 wasted repeat calls, 53.1% of error calls inside a streak. fetch-all passes context=null so the driver message stays VERBATIM (sanitizeDbError and other callers match on the existing text; this change adds the code, it does not reword). Attaching `code` is safe because extractCode() only accepts /^[A-Z_]+$/ and every SQLSTATE/PostgREST code contains digits, so it cannot hijack the application error registry (pinned by a test). dbError also never renders the literal "undefined": a driver-level failure with no message produced "Database error: undefined", the string that made these unsearchable. errorCauseTag() returns a PII-safe SQLSTATE for telemetry; the raw driver message can quote row values in a constraint violation and belongs in the server log, never in event_log. NOT ratcheted: check:types reports 538 vs baseline 539 because main fixed an unrelated error in own-account-detector.test.ts after the baseline was set; the gate only fails on an INCREASE, so the baseline is left alone rather than adding unrelated churn to this diff.
@@ -227,17 +227,30 @@ interface SkipReasons {
}
interface MigrationStepError {
step: 'companyInfo' | 'customers' | 'suppliers' | 'salesInvoices' | 'supplierInvoices' | 'reconciliation'
step: 'companyInfo' | 'customers' | 'suppliers' | 'salesInvoices' | 'supplierInvoices' | 'registrationLinks' | 'reconciliation'
code: string | null
message: string
}
/** Mirrors MigrationResults.registrationLinks in extensions/general/arcim-migration/types.ts. */
interface RegistrationLinkCounts {
scanned: number
linked: number
noRef: number
refNotFetched: number
unresolved: number
ambiguous: number
amountMismatch: number
alreadyLinked: number
}
interface MigrationResults {
companyInfo?: { imported: boolean }
customers?: { total: number; imported: number; updated?: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
registrationLinks?: RegistrationLinkCounts
stepErrors?: MigrationStepError[]
}
import AccountMappingStep from '@/components/import/AccountMappingStep'
@@ -1711,6 +1724,7 @@ function ResultStep({
onDismissDocuments: () => void
onReconnectDocuments: () => void
}) {
const t = useTranslations('extensions')
if (error) {
return (
<div className="stagger-enter space-y-8">
@@ -1835,6 +1849,24 @@ function ResultStep({
failed: entityRowStatus(results.supplierInvoices.imported, results.supplierInvoices.skipReasons) === 'error',
})
}
if (results.registrationLinks && results.registrationLinks.scanned > 0) {
const links = results.registrationLinks
const unlinked = links.scanned - links.linked - links.alreadyLinked
entityLines.push({
label: t('ext_arcim_registration_links_label'),
value: t('ext_arcim_registration_links_value', { linked: links.linked, scanned: links.scanned }),
detail: unlinked > 0
? t('ext_arcim_registration_links_detail', {
unlinked,
noRef: links.noRef,
refNotFetched: links.refNotFetched ?? 0,
unresolved: links.unresolved + links.ambiguous,
amountMismatch: links.amountMismatch,
})
: undefined,
failed: false,
})
}
}
return (
@@ -1974,6 +2006,7 @@ const STEP_ERROR_LABELS: Record<MigrationStepError['step'], string> = {
suppliers: 'Leverantörer',
salesInvoices: 'Kundfakturor',
supplierInvoices: 'Leverantörsfakturor',
registrationLinks: 'Koppling till verifikationer',
reconciliation: 'Avstämning av betalningar',
}
@@ -0,0 +1,235 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
/**
* Locks the registration-voucher link step of the migration (#1463): the
* orchestrator collects the id of every invoice it inserted together with the
* voucher ref the provider named, hands that list to the core linker once,
* after both invoice steps, and carries the counts into the results. A
* linker failure is recorded as a step error and never discards the invoices
* that were already persisted.
*/
vi.mock('@/lib/providers/resolve-consent', () => ({
resolveConsent: vi.fn().mockResolvedValue({
consent: { provider: 'visma' },
accessToken: 'tok',
providerCompanyId: null,
}),
}))
vi.mock('@/lib/providers/provider-data-fetcher', () => ({
fetchCompanyInfoDirect: vi.fn(),
fetchCustomersDirect: vi.fn(),
fetchSuppliersDirect: vi.fn(),
fetchSalesInvoicesHydrated: vi.fn(),
fetchSupplierInvoicesHydrated: vi.fn(),
}))
vi.mock('@/lib/invoices/bulk-reconcile-supplier-vouchers', () => ({
reconcileSupplierInvoiceVouchers: vi.fn(),
}))
vi.mock('@/lib/invoices/link-migrated-registration-vouchers', () => ({
linkMigratedRegistrationVouchers: vi.fn(),
}))
vi.mock('@/lib/supabase/fetch-all', () => ({
fetchAllRows: vi.fn().mockResolvedValue([]),
}))
// Inserts answer with deterministic ids so the collected inputs can be
// asserted; the per-row fallback itself has its own suite.
vi.mock('../lib/insert-fallback', () => ({
insertWithPerRowFallback: vi.fn(async (_supabase: unknown, table: string, rows: Record<string, unknown>[]) => ({
returned: rows.map((row, i) => ({
id: `${table}-${i + 1}`,
org_number: row.org_number ?? null,
name: row.name ?? null,
})),
failedCount: 0,
firstError: null,
})),
}))
import { executeMigration } from '../lib/migration-orchestrator'
import {
fetchSalesInvoicesHydrated,
fetchSupplierInvoicesHydrated,
} from '@/lib/providers/provider-data-fetcher'
import { linkMigratedRegistrationVouchers } from '@/lib/invoices/link-migrated-registration-vouchers'
import type { SalesInvoiceDto, SupplierInvoiceDto } from '@/lib/providers/dto'
const mLink = linkMigratedRegistrationVouchers as Mock
const HYDRATION = { needed: 0, hydrated: 0, failed: 0, skippedForBudget: 0 }
function party(name: string) {
return { name, identifications: [] }
}
function salesDto(over: Partial<SalesInvoiceDto> & { invoiceNumber: string }): SalesInvoiceDto {
return {
id: over.invoiceNumber,
issueDate: '2025-03-14',
dueDate: '2025-04-13',
currencyCode: 'SEK',
status: 'sent',
supplier: party(''),
customer: party('Kund AB'),
lines: [],
legalMonetaryTotal: { payableAmount: { value: 1000, currencyCode: 'SEK' } },
taxTotal: { taxAmount: { value: 200, currencyCode: 'SEK' } },
paymentStatus: { paid: false, balance: { value: 1000, currencyCode: 'SEK' } },
...over,
}
}
function supplierDto(over: Partial<SupplierInvoiceDto> & { invoiceNumber: string }): SupplierInvoiceDto {
return {
id: over.invoiceNumber,
issueDate: '2025-05-02',
dueDate: '2025-06-01',
currencyCode: 'SEK',
status: 'booked',
supplier: party('Leverantör AB'),
buyer: party(''),
lines: [],
legalMonetaryTotal: { payableAmount: { value: 2500, currencyCode: 'SEK' } },
taxTotal: { taxAmount: { value: 500, currencyCode: 'SEK' } },
paymentStatus: { paid: false, balance: { value: 2500, currencyCode: 'SEK' } },
...over,
}
}
const LINK_COUNTS = {
scanned: 2, linked: 1, noRef: 1, refNotFetched: 0, unresolved: 0, ambiguous: 0, amountMismatch: 0, alreadyLinked: 0, reports: [],
}
function baseOptions(overrides: Record<string, unknown> = {}) {
const { supabase } = createQueuedMockSupabase()
return {
consentId: 'consent-1',
companyId: 'company-1',
userId: 'user-1',
supabase: supabase as unknown as SupabaseClient,
importCompanyInfo: false,
importCustomers: false,
importSuppliers: false,
importSalesInvoices: false,
importSupplierInvoices: false,
reconcileVouchers: false,
...overrides,
}
}
describe('executeMigration: registration voucher links', () => {
beforeEach(() => {
vi.clearAllMocks()
mLink.mockResolvedValue(LINK_COUNTS)
})
it('hands every inserted invoice, with its provider voucher ref, to the linker once and reports the counts', async () => {
;(fetchSalesInvoicesHydrated as Mock).mockResolvedValue({
invoices: [
salesDto({ invoiceNumber: '1001', sourceVoucher: { series: 'A', number: 329 } }),
salesDto({ invoiceNumber: '1002' }),
salesDto({ invoiceNumber: '1003', currencyCode: 'EUR' }),
],
hydration: { ...HYDRATION, needed: 1, skippedForBudget: 1 },
// 1003's detail form was never fetched: its ref is unknown, not absent.
unhydratedIds: new Set(['1003']),
})
;(fetchSupplierInvoicesHydrated as Mock).mockResolvedValue({
invoices: [supplierDto({ invoiceNumber: 'L-77', sourceVoucher: { series: 'B', number: 5 } })],
hydration: HYDRATION,
unhydratedIds: new Set(),
})
const results = await executeMigration(
baseOptions({ importSalesInvoices: true, importSupplierInvoices: true }),
)
expect(mLink).toHaveBeenCalledTimes(1)
const call = mLink.mock.calls[0][0]
expect(call.companyId).toBe('company-1')
expect(call.invoices).toEqual([
{
invoiceId: 'invoices-1',
kind: 'customer',
sourceVoucher: { series: 'A', number: 329 },
refNotFetched: false,
invoiceDate: '2025-03-14',
totalSek: 1000,
currencyCode: 'SEK',
invoiceNumber: '1001',
},
{
invoiceId: 'invoices-2',
kind: 'customer',
sourceVoucher: null,
refNotFetched: false,
invoiceDate: '2025-03-14',
totalSek: 1000,
currencyCode: 'SEK',
invoiceNumber: '1002',
},
{
invoiceId: 'invoices-3',
kind: 'customer',
sourceVoucher: null,
refNotFetched: true,
invoiceDate: '2025-03-14',
// The SEK total comes from the run's rate index; the linker gets the
// currency so a mismatch can be explained as a rate difference.
totalSek: expect.any(Number),
currencyCode: 'EUR',
invoiceNumber: '1003',
},
{
invoiceId: 'supplier_invoices-1',
kind: 'supplier',
sourceVoucher: { series: 'B', number: 5 },
refNotFetched: false,
invoiceDate: '2025-05-02',
totalSek: 2500,
currencyCode: 'SEK',
invoiceNumber: 'L-77',
},
])
expect(results.registrationLinks).toEqual({
scanned: 2, linked: 1, noRef: 1, refNotFetched: 0, unresolved: 0, ambiguous: 0, amountMismatch: 0, alreadyLinked: 0,
})
expect(results.salesInvoices?.imported).toBe(3)
expect(results.supplierInvoices?.imported).toBe(1)
expect(results.stepErrors).toBeUndefined()
})
it('skips the linker entirely when no invoice was inserted', async () => {
;(fetchSalesInvoicesHydrated as Mock).mockResolvedValue({ invoices: [], hydration: HYDRATION, unhydratedIds: new Set() })
const results = await executeMigration(baseOptions({ importSalesInvoices: true }))
expect(mLink).not.toHaveBeenCalled()
expect(results.registrationLinks).toBeUndefined()
})
it('records a linker failure as a step error and keeps the imported invoices', async () => {
;(fetchSalesInvoicesHydrated as Mock).mockResolvedValue({
invoices: [salesDto({ invoiceNumber: '1001', sourceVoucher: { series: 'A', number: 1 } })],
hydration: HYDRATION,
unhydratedIds: new Set(),
})
mLink.mockRejectedValue(new Error('db down'))
const results = await executeMigration(baseOptions({ importSalesInvoices: true }))
expect(results.salesInvoices?.imported).toBe(1)
expect(results.registrationLinks).toBeUndefined()
expect(results.stepErrors).toEqual([
expect.objectContaining({ step: 'registrationLinks' }),
])
})
})
@@ -0,0 +1,158 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
import { createMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
/**
* POST /reconcile (#1463): the payment reconcile runs as before, and
* `{ consentId }` adds the registration-voucher relink. The consent is
* validated company-scoped BEFORE the payment reconcile writes anything (a
* wrong id is a clean 404, not a 500 after a write), and a relink failure is
* reported beside the payment result that was already persisted instead of
* discarding it.
*/
vi.mock('../lib/migration-orchestrator', () => ({
executeMigration: vi.fn(),
}))
vi.mock('../lib/provider-client', () => ({
createConsent: vi.fn(),
getConsent: vi.fn(),
listConsents: vi.fn(),
generateOtc: vi.fn(),
consumeOAuthState: vi.fn(),
getAuthUrl: vi.fn(),
exchangeAuthToken: vi.fn(),
submitProviderToken: vi.fn(),
acceptConsent: vi.fn(),
deleteConsent: vi.fn(),
resolveConsent: vi.fn(),
fetchCompanyInfoDirect: vi.fn(),
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
}))
vi.mock('@/lib/invoices/bulk-reconcile-supplier-vouchers', () => ({
reconcileSupplierInvoiceVouchers: vi.fn(),
}))
vi.mock('../lib/relink-registration-vouchers', () => ({
relinkRegistrationVouchers: vi.fn(),
}))
import { arcimMigrationExtension } from '../index'
import { getConsent, ConsentNotFoundError } from '../lib/provider-client'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
import { relinkRegistrationVouchers } from '../lib/relink-registration-vouchers'
const route = (arcimMigrationExtension.apiRoutes ?? []).find(
(r) => r.method === 'POST' && r.path === '/reconcile',
)!
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
const handler = route.handler as RouteHandler
const mReconcile = reconcileSupplierInvoiceVouchers as Mock
const mRelink = relinkRegistrationVouchers as Mock
const mGetConsent = getConsent as Mock
const PAYMENT_RESULT = { scanned: 3, autoLinked: 2, ambiguous: 0, unmatched: 1, items: [] }
const LINK_RESULT = {
scanned: 2, linked: 1, noRef: 0, refNotFetched: 1, unresolved: 0, ambiguous: 0, amountMismatch: 0, alreadyLinked: 0,
reports: [], providerInvoices: 2, matched: 2, unmatched: 0,
hydration: {
sales: { needed: 1, hydrated: 0, failed: 0, skippedForBudget: 1 },
supplier: { needed: 0, hydrated: 0, failed: 0, skippedForBudget: 0 },
},
}
function buildCtx(user: { id: string } | null = { id: 'user-1' }): ExtensionContext {
const { supabase } = createMockSupabase()
;(supabase as unknown as { auth: unknown }).auth = {
getUser: vi.fn().mockResolvedValue({ data: { user } }),
}
return { supabase, companyId: 'company-1', log: { info: vi.fn(), error: vi.fn(), warn: vi.fn() } } as unknown as ExtensionContext
}
function reconcileRequest(body?: Record<string, unknown>) {
return createMockRequest('http://localhost/api/extensions/ext/arcim-migration/reconcile', {
method: 'POST',
...(body ? { body } : {}),
})
}
describe('POST /reconcile', () => {
beforeEach(() => {
vi.clearAllMocks()
mReconcile.mockResolvedValue(PAYMENT_RESULT)
mRelink.mockResolvedValue(LINK_RESULT)
mGetConsent.mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
})
it('returns 401 without a user and touches nothing', async () => {
const res = await handler(reconcileRequest({ consentId: 'consent-1' }), buildCtx(null))
expect(res.status).toBe(401)
expect(mGetConsent).not.toHaveBeenCalled()
expect(mReconcile).not.toHaveBeenCalled()
expect(mRelink).not.toHaveBeenCalled()
})
it('without consentId runs the payment reconcile only and answers as before', async () => {
const res = await handler(reconcileRequest({ dryRun: true }), buildCtx())
const { status, body } = await parseJsonResponse<Record<string, unknown>>(res)
expect(status).toBe(200)
expect(body).toEqual({ success: true, dryRun: true, result: PAYMENT_RESULT })
expect(mReconcile).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'company-1', userId: 'user-1', dryRun: true }))
expect(mGetConsent).not.toHaveBeenCalled()
expect(mRelink).not.toHaveBeenCalled()
})
it('with consentId validates the consent, runs both passes and returns the link counts', async () => {
const res = await handler(reconcileRequest({ consentId: 'consent-1', dryRun: true }), buildCtx())
const { status, body } = await parseJsonResponse<Record<string, unknown>>(res)
expect(status).toBe(200)
expect(body).toEqual({ success: true, dryRun: true, result: PAYMENT_RESULT, registrationLinks: LINK_RESULT })
expect(mGetConsent).toHaveBeenCalledWith('consent-1', 'company-1')
expect(mRelink).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'company-1', consentId: 'consent-1', dryRun: true }))
})
it('answers 404 PROVIDER_CONSENT_NOT_FOUND for a foreign or unknown consent, before any write', async () => {
mGetConsent.mockRejectedValue(new ConsentNotFoundError())
const res = await handler(reconcileRequest({ consentId: 'consent-x' }), buildCtx())
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(404)
expect(body.error.code).toBe('PROVIDER_CONSENT_NOT_FOUND')
expect(mReconcile).not.toHaveBeenCalled()
expect(mRelink).not.toHaveBeenCalled()
})
it('keeps the persisted payment result when the relink itself fails, and names the failure', async () => {
mRelink.mockRejectedValue({ status: 404, message: 'Consent not found' })
const res = await handler(reconcileRequest({ consentId: 'consent-1' }), buildCtx())
const { status, body } = await parseJsonResponse<Record<string, unknown>>(res)
expect(status).toBe(200)
expect(body).toEqual({
success: true,
dryRun: false,
result: PAYMENT_RESULT,
registrationLinks: null,
registrationLinksError: { code: 'PROVIDER_CONSENT_NOT_FOUND' },
})
})
it('classifies an unknown relink error as PROVIDER_MIGRATE_FAILED without leaking its message', async () => {
mRelink.mockRejectedValue(new Error('socket hang up at 10.0.0.1'))
const res = await handler(reconcileRequest({ consentId: 'consent-1' }), buildCtx())
const { body } = await parseJsonResponse<{ registrationLinksError: { code: string } }>(res)
expect(body.registrationLinksError).toEqual({ code: 'PROVIDER_MIGRATE_FAILED' })
expect(JSON.stringify(body)).not.toContain('10.0.0.1')
})
})
+69 -3
View File
@@ -25,6 +25,7 @@ import {
importProviderDocuments,
} from './lib/import-documents'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
import { relinkRegistrationVouchers } from './lib/relink-registration-vouchers'
import type { ArcimProvider } from './types'
import { ARCIM_PROVIDERS } from './types'
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
@@ -1320,6 +1321,16 @@ export const arcimMigrationExtension: Extension = {
// auto-link settled supplier invoices to their existing vouchers. Pass
// { dryRun: true } to preview the plan (incl. items needing manual review)
// without writing.
//
// Pass { consentId } to ALSO re-link registration vouchers (the verifikat
// that BOOKED each invoice). The imported rows do not store the provider's
// voucher ref, so that pass re-fetches both registers from the provider
// through the given consent; without a consentId it is skipped and the
// response carries no `registrationLinks`. The consent is validated
// (company-scoped) BEFORE the payment reconcile writes anything, so a
// wrong id is a clean 404; a provider failure during the relink itself is
// reported beside the payment result, which was already persisted, as
// `registrationLinksError` rather than by discarding that result.
{
method: 'POST',
path: '/reconcile',
@@ -1335,15 +1346,29 @@ export const arcimMigrationExtension: Extension = {
const companyId = ctx?.companyId ?? user.id
let dryRun = false
let consentId: string | null = null
try {
const body = (await request.json()) as { dryRun?: boolean }
const body = (await request.json()) as { dryRun?: boolean; consentId?: unknown }
dryRun = body?.dryRun === true
consentId = typeof body?.consentId === 'string' && body.consentId ? body.consentId : null
} catch {
// empty body is fine: default to a real run
}
if (consentId) {
// A foreign consent throws the same ConsentNotFoundError as a
// nonexistent one (no cross-tenant existence oracle).
try {
await getConsent(consentId, companyId)
} catch (error) {
log.error('arcim reconcile: consent lookup failed', error as Error)
return migrateFailureResponse(error, consentId)
}
}
let result: Awaited<ReturnType<typeof reconcileSupplierInvoiceVouchers>>
try {
const result = await reconcileSupplierInvoiceVouchers({
result = await reconcileSupplierInvoiceVouchers({
supabase,
companyId,
userId: user.id,
@@ -1356,13 +1381,54 @@ export const arcimMigrationExtension: Extension = {
ambiguous: result.ambiguous,
unmatched: result.unmatched,
})
return NextResponse.json({ success: true, dryRun, result })
} catch (error) {
log.error('arcim reconcile failed', error as Error)
return errorResponseFromCode('PROVIDER_MIGRATE_FAILED', moduleLog, {
details: { reason: error instanceof Error ? error.message : 'unknown' },
})
}
if (!consentId) {
return NextResponse.json({ success: true, dryRun, result })
}
try {
const registrationLinks = await relinkRegistrationVouchers({
supabase,
companyId,
consentId,
dryRun,
})
log.info('arcim registration relink completed', {
companyId,
dryRun,
providerInvoices: registrationLinks.providerInvoices,
matched: registrationLinks.matched,
linked: registrationLinks.linked,
refNotFetched: registrationLinks.refNotFetched,
ambiguous: registrationLinks.ambiguous,
amountMismatch: registrationLinks.amountMismatch,
})
return NextResponse.json({ success: true, dryRun, result, registrationLinks })
} catch (error) {
// resolveConsent throws plain `{ status, message }` objects for a
// consent that vanished or lost its tokens between the check above
// and here; classifyProviderError handles the provider-side ones.
log.error('arcim registration relink failed', error as Error)
const status = typeof error === 'object' && error !== null && 'status' in error
? (error as { status?: unknown }).status
: undefined
const code = error instanceof ConsentNotFoundError || status === 404
? 'PROVIDER_CONSENT_NOT_FOUND'
: classifyProviderError(error) ?? 'PROVIDER_MIGRATE_FAILED'
return NextResponse.json({
success: true,
dryRun,
result,
registrationLinks: null,
registrationLinksError: { code },
})
}
},
},
@@ -0,0 +1,218 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* The /reconcile re-run for registration vouchers (#1463): re-fetch both
* registers from the provider, join them to the invoices that are still
* unlinked here, and hand the pairs to the core linker. The joins are strict
* (sales by invoice number, supplier by number + date, unique on both sides)
* because a wrong join hands the linker a plausible but wrong candidate.
*/
vi.mock('@/lib/providers/resolve-consent', () => ({
resolveConsent: vi.fn().mockResolvedValue({
consent: { provider: 'fortnox' },
accessToken: 'tok',
providerCompanyId: undefined,
}),
}))
vi.mock('@/lib/providers/provider-data-fetcher', () => ({
fetchSalesInvoicesHydrated: vi.fn(),
fetchSupplierInvoicesHydrated: vi.fn(),
}))
vi.mock('@/lib/supabase/fetch-all', () => ({ fetchAllRows: vi.fn() }))
vi.mock('@/lib/invoices/link-migrated-registration-vouchers', () => ({
linkMigratedRegistrationVouchers: vi.fn(),
}))
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
fetchSalesInvoicesHydrated,
fetchSupplierInvoicesHydrated,
} from '@/lib/providers/provider-data-fetcher'
import { linkMigratedRegistrationVouchers } from '@/lib/invoices/link-migrated-registration-vouchers'
import { relinkRegistrationVouchers } from '../relink-registration-vouchers'
const mFetchAll = fetchAllRows as Mock
const mSales = fetchSalesInvoicesHydrated as Mock
const mSupplier = fetchSupplierInvoicesHydrated as Mock
const mLink = linkMigratedRegistrationVouchers as Mock
const HYDRATION = { needed: 0, hydrated: 0, failed: 0, skippedForBudget: 0 }
const EMPTY_COUNTS = {
scanned: 0, linked: 0, noRef: 0, refNotFetched: 0, unresolved: 0, ambiguous: 0, amountMismatch: 0, alreadyLinked: 0, reports: [],
}
function providerSales(invoiceNumber: string, ref?: { series: string | null; number: number }, issueDate = '2025-03-14') {
return { id: invoiceNumber, invoiceNumber, issueDate, sourceVoucher: ref }
}
function providerSupplier(invoiceNumber: string, issueDate: string, ref?: { series: string | null; number: number }) {
return { id: `sup-${invoiceNumber}`, invoiceNumber, issueDate, sourceVoucher: ref }
}
function hydrated(invoices: unknown[], unhydratedIds: string[] = []) {
return { invoices, hydration: HYDRATION, unhydratedIds: new Set(unhydratedIds) }
}
/** fetchAllRows is called twice: unlinked sales rows, then unlinked supplier rows. */
function queueDb(sales: unknown[], supplier: unknown[]) {
mFetchAll.mockReset()
mFetchAll.mockResolvedValueOnce(sales).mockResolvedValueOnce(supplier)
}
const supabase = {} as unknown as SupabaseClient
describe('relinkRegistrationVouchers', () => {
beforeEach(() => {
vi.clearAllMocks()
mLink.mockImplementation(async ({ invoices }: { invoices: unknown[] }) => ({
...EMPTY_COUNTS,
scanned: invoices.length,
}))
})
it('joins provider invoices to the unlinked rows and hands the pairs to the linker', async () => {
mSales.mockResolvedValue(hydrated([providerSales('1001', { series: 'A', number: 329 }), providerSales('9999')]))
mSupplier.mockResolvedValue(hydrated([providerSupplier('L-77', '2025-05-02', { series: 'B', number: 5 })]))
queueDb(
[{ id: 'inv-1', invoice_number: '1001', invoice_date: '2025-03-14', total_sek: 1000, currency: 'SEK' }],
[{ id: 'si-1', supplier_invoice_number: 'L-77', invoice_date: '2025-05-02', total_sek: 2500, currency: 'EUR' }],
)
const result = await relinkRegistrationVouchers({ supabase, companyId: 'company-1', consentId: 'consent-1' })
expect(mLink).toHaveBeenCalledTimes(1)
expect(mLink.mock.calls[0][0]).toMatchObject({ companyId: 'company-1', dryRun: false })
expect(mLink.mock.calls[0][0].invoices).toEqual([
{
invoiceId: 'inv-1',
kind: 'customer',
sourceVoucher: { series: 'A', number: 329 },
refNotFetched: false,
invoiceDate: '2025-03-14',
totalSek: 1000,
currencyCode: 'SEK',
invoiceNumber: '1001',
},
{
invoiceId: 'si-1',
kind: 'supplier',
sourceVoucher: { series: 'B', number: 5 },
refNotFetched: false,
invoiceDate: '2025-05-02',
totalSek: 2500,
currencyCode: 'EUR',
invoiceNumber: 'L-77',
},
])
expect(result).toMatchObject({
providerInvoices: 3,
matched: 2,
unmatched: 1,
scanned: 2,
hydration: { sales: HYDRATION, supplier: HYDRATION },
})
})
it('flags an invoice whose detail payload was never fetched as refNotFetched instead of noRef', async () => {
// Fortnox carries VoucherSeries/VoucherNumber only on the detail form. An
// invoice the hydration budget did not reach has no ref in the DTO, but
// that is "unknown", not "the provider has none".
mSales.mockResolvedValue(hydrated([providerSales('1001'), providerSales('1002')], ['1002']))
mSupplier.mockResolvedValue(hydrated([]))
queueDb(
[
{ id: 'inv-1', invoice_number: '1001', invoice_date: '2025-03-14', total_sek: 1000, currency: 'SEK' },
{ id: 'inv-2', invoice_number: '1002', invoice_date: '2025-03-14', total_sek: 1000, currency: 'SEK' },
],
[],
)
await relinkRegistrationVouchers({ supabase, companyId: 'company-1', consentId: 'consent-1' })
expect(mLink.mock.calls[0][0].invoices).toEqual([
expect.objectContaining({ invoiceId: 'inv-1', sourceVoucher: null, refNotFetched: false }),
expect.objectContaining({ invoiceId: 'inv-2', sourceVoucher: null, refNotFetched: true }),
])
})
it('joins sales invoices on number AND date, so a native invoice reusing a provider number is not handed over', async () => {
mSales.mockResolvedValue(hydrated([
providerSales('1001', { series: 'A', number: 329 }, '2025-03-14'),
providerSales('1002', { series: 'A', number: 330 }, '2025-03-15T00:00:00'),
]))
mSupplier.mockResolvedValue(hydrated([]))
queueDb(
[
// Same number as the provider's 1001 but a different date: a native
// invoice, not the migrated one.
{ id: 'inv-native', invoice_number: '1001', invoice_date: '2025-09-01', total_sek: 1000, currency: 'SEK' },
// Datetime on the provider side joins on its date part.
{ id: 'inv-2', invoice_number: '1002', invoice_date: '2025-03-15', total_sek: 1000, currency: 'SEK' },
],
[],
)
const result = await relinkRegistrationVouchers({ supabase, companyId: 'company-1', consentId: 'consent-1' })
expect(mLink.mock.calls[0][0].invoices.map((i: { invoiceId: string }) => i.invoiceId)).toEqual(['inv-2'])
expect(result).toMatchObject({ providerInvoices: 2, matched: 1, unmatched: 1 })
})
it('refuses to join a supplier invoice number shared by two rows on either side', async () => {
mSales.mockResolvedValue(hydrated([]))
mSupplier.mockResolvedValue(hydrated([
providerSupplier('1001', '2025-05-02', { series: 'B', number: 5 }),
providerSupplier('1001', '2025-05-02', { series: 'B', number: 6 }),
providerSupplier('2002', '2025-06-01', { series: 'B', number: 7 }),
]))
queueDb(
[],
[
{ id: 'si-a', supplier_invoice_number: '1001', invoice_date: '2025-05-02', total_sek: 100 },
{ id: 'si-b', supplier_invoice_number: '2002', invoice_date: '2025-06-01', total_sek: 200 },
{ id: 'si-c', supplier_invoice_number: '2002', invoice_date: '2025-06-01', total_sek: 300 },
],
)
const result = await relinkRegistrationVouchers({ supabase, companyId: 'company-1', consentId: 'consent-1' })
expect(mLink.mock.calls[0][0].invoices).toEqual([])
expect(result).toMatchObject({ providerInvoices: 3, matched: 0, unmatched: 3 })
})
it('reads only non-draft rows whose link is still NULL and passes dryRun through', async () => {
mSales.mockResolvedValue(hydrated([]))
mSupplier.mockResolvedValue(hydrated([]))
// Capture the query builders to assert the NULL guards.
const calls: { table: string; method: string; args: unknown[] }[] = []
const chain = (table: string): unknown => new Proxy({}, {
get: (_t, prop) => (...args: unknown[]) => {
calls.push({ table, method: String(prop), args })
return chain(table)
},
})
const capturing = { from: (table: string) => chain(table) } as unknown as SupabaseClient
mFetchAll.mockReset()
mFetchAll.mockImplementation(async (queryFn: (r: { from: number; to: number }) => unknown) => {
queryFn({ from: 0, to: 999 })
return []
})
await relinkRegistrationVouchers({ supabase: capturing, companyId: 'company-1', consentId: 'consent-1', dryRun: true })
expect(calls).toEqual(expect.arrayContaining([
{ table: 'invoices', method: 'is', args: ['journal_entry_id', null] },
{ table: 'invoices', method: 'eq', args: ['company_id', 'company-1'] },
{ table: 'invoices', method: 'neq', args: ['status', 'draft'] },
{ table: 'supplier_invoices', method: 'is', args: ['registration_journal_entry_id', null] },
{ table: 'supplier_invoices', method: 'eq', args: ['company_id', 'company-1'] },
]))
expect(mLink.mock.calls[0][0]).toMatchObject({ dryRun: true, invoices: [] })
})
})
@@ -35,6 +35,10 @@ import {
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { createLogger } from '@/lib/logger'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
import {
linkMigratedRegistrationVouchers,
type MigratedInvoiceLinkInput,
} from '@/lib/invoices/link-migrated-registration-vouchers'
import {
buildCustomerMetadataEnrichment,
type CustomerMetadataEnrichment,
@@ -157,6 +161,10 @@ function logFxUnresolved(kind: string, invoiceNumber: string, fx: FxUnresolved):
export async function executeMigration(options: MigrationOptions): Promise<MigrationResults> {
const { consentId, companyId, userId, supabase } = options
const results: MigrationResults = {}
// Every invoice this run inserted, with the booking voucher the provider
// named for it. Linked to the SIE-imported registration verifikat after both
// invoice steps (see the registration-link step below).
const registrationLinkInputs: MigratedInvoiceLinkInput[] = []
// Resolve consent to get access token and provider
const resolved = await resolveConsent(companyId, consentId)
@@ -490,7 +498,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
try {
// Hydrated, not the bare list: the list payload omits VAT, the net
// and the line items for most providers (see provider-data-fetcher).
const { invoices, hydration } = await fetchSalesInvoicesHydrated(
const { invoices, hydration, unhydratedIds } = await fetchSalesInvoicesHydrated(
provider, accessToken, providerCompanyId,
)
console.log(`[migration] Sales invoices: ${invoices.length} total`)
@@ -677,6 +685,16 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
for (const item of mappedBatch[i].items) {
allItems.push({ ...item, invoice_id: invoiceId })
}
registrationLinkInputs.push({
invoiceId: String(invoiceId),
kind: 'customer',
sourceVoucher: mappedBatch[i].dto.sourceVoucher ?? null,
refNotFetched: unhydratedIds.has(mappedBatch[i].dto.id),
invoiceDate: mappedBatch[i].dto.issueDate,
totalSek: mappedBatch[i].invoice.total_sek as number | null,
currencyCode: mappedBatch[i].dto.currencyCode || 'SEK',
invoiceNumber: mappedBatch[i].dto.invoiceNumber || null,
})
const fx = mappedBatch[i].fxUnresolved
if (fx) {
fxUnresolved++
@@ -713,7 +731,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (options.importSupplierInvoices !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörsfakturor...', progress: 80 })
try {
const { invoices, hydration } = await fetchSupplierInvoicesHydrated(
const { invoices, hydration, unhydratedIds } = await fetchSupplierInvoicesHydrated(
provider, accessToken, providerCompanyId,
)
console.log(`[migration] Supplier invoices: ${invoices.length} total`)
@@ -914,6 +932,16 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
for (const item of mappedBatch[i].items) {
allItems.push({ ...item, supplier_invoice_id: invoiceId })
}
registrationLinkInputs.push({
invoiceId: String(invoiceId),
kind: 'supplier',
sourceVoucher: mappedBatch[i].dto.sourceVoucher ?? null,
refNotFetched: unhydratedIds.has(mappedBatch[i].dto.id),
invoiceDate: mappedBatch[i].dto.issueDate,
totalSek: mappedBatch[i].invoice.total_sek as number | null,
currencyCode: mappedBatch[i].dto.currencyCode || 'SEK',
invoiceNumber: mappedBatch[i].dto.invoiceNumber || null,
})
const fx = mappedBatch[i].fxUnresolved
if (fx) {
fxUnresolved++
@@ -946,6 +974,41 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
// ── Step 5b: Link imported invoices to their registration vouchers ──
// The provider named the verifikat that booked each invoice ("A329");
// the SIE import preserved that source ref on the entry it created. Link
// the two where the match is exact and amount-corroborated, so migrated
// invoices stop reading as unbooked. Writes only the invoice-side FK from
// NULL; never touches journal entries. Best-effort like step 6: the
// invoices are already persisted, and /reconcile can re-run this.
if (registrationLinkInputs.length > 0) {
emitProgress(options, { status: 'importing', currentStep: 'Kopplar fakturor till verifikationer...', progress: 90 })
try {
const links = await linkMigratedRegistrationVouchers({
supabase,
companyId,
invoices: registrationLinkInputs,
})
results.registrationLinks = {
scanned: links.scanned,
linked: links.linked,
noRef: links.noRef,
refNotFetched: links.refNotFetched,
unresolved: links.unresolved,
ambiguous: links.ambiguous,
amountMismatch: links.amountMismatch,
alreadyLinked: links.alreadyLinked,
}
console.log(
`[migration] Registration vouchers: ${links.linked} linked, ${links.noRef} without ref, ${links.refNotFetched} ref not fetched, ${links.unresolved} unresolved, `
+ `${links.ambiguous} ambiguous, ${links.amountMismatch} amount mismatch, ${links.alreadyLinked} already linked (${links.scanned} scanned)`,
)
} catch (err) {
console.error('Failed to link registration vouchers:', err)
recordStepError(results, 'registrationLinks', err)
}
}
// ── Step 6: Reconcile supplier invoices to GL payment vouchers ────
// The GL (incl. the Dr 2440 / Cr 1930 bank-payment vouchers) is imported
// separately via SIE. Supplier invoices arrive (via ?filter=unpaid) as open
@@ -0,0 +1,196 @@
/**
* Re-run the registration-voucher link for a company that was migrated
* before the migration wrote it (or whose GL landed via SIE after the
* invoices did).
*
* The imported invoice rows do not store the provider's voucher ref, so the
* only way to recover it is to ask the provider again: the registers are
* re-fetched (hydrated, so Fortnox detail payloads carry VoucherSeries /
* VoucherNumber), joined to the invoices that are still unlinked, and handed
* to the core linker, which decides and writes with the same guarantees the
* migration itself uses. Nothing is inserted: an invoice the provider knows
* but this company does not is skipped, not imported.
*
* Join keys are deliberately strict: invoice number AND invoice date, on
* both registers, and only when that pair is unique on both sides. The
* unlinked rows are read from the whole company, so a native (non-migrated)
* invoice that is unbooked for its own reasons (a kontantmetod invoice, a
* draft) sits next to the migrated ones; requiring the date as well as the
* number, and excluding sales drafts (a migrated row is a draft only when the
* source had no voucher for it anyway; supplier_invoices has no draft status),
* keeps a native invoice that happens to reuse a provider number from being
* handed to the linker. Two suppliers can legitimately issue "1001" in the
* same year, and a wrong join would hand the linker a plausible but wrong
* candidate.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { ISO_DATE_RE } from '@/lib/invariants'
import type { ProviderName } from '@/lib/providers/types'
import { resolveConsent } from '@/lib/providers/resolve-consent'
import {
fetchSalesInvoicesHydrated,
fetchSupplierInvoicesHydrated,
type HydrationReport,
} from '@/lib/providers/provider-data-fetcher'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
linkMigratedRegistrationVouchers,
type MigratedInvoiceLinkInput,
type RegistrationLinkResult,
} from '@/lib/invoices/link-migrated-registration-vouchers'
export interface RelinkRegistrationVouchersOptions {
supabase: SupabaseClient
companyId: string
consentId: string
dryRun?: boolean
}
export interface RelinkRegistrationVouchersResult extends RegistrationLinkResult {
/** Invoices the provider returned (both registers). */
providerInvoices: number
/** Provider invoices that joined to an unlinked invoice here and were scanned. */
matched: number
/** Provider invoices with no unlinked counterpart here (already linked, never imported, or ambiguous join). */
unmatched: number
/**
* What the provider re-fetch managed to hydrate. When a budget was
* exhausted, the `refNotFetched` bucket above is where those invoices went,
* and a re-run (or a larger budget) can still link them.
*/
hydration: { sales: HydrationReport; supplier: HydrationReport }
}
interface UnlinkedSalesRow {
id: string
invoice_number: string | null
invoice_date: string
total_sek: number | null
currency: string | null
}
interface UnlinkedSupplierRow {
id: string
supplier_invoice_number: string | null
invoice_date: string
total_sek: number | null
currency: string | null
}
/**
* "number::YYYY-MM-DD". The DB renders a `date` column as YYYY-MM-DD; the
* provider's issue date is passed through the mapper untouched, so it is
* trimmed to the same ten characters in case a provider ever ships a
* datetime. A date that does not start like an ISO date joins nothing.
*/
function joinKey(number: string | null | undefined, date: string | null | undefined): string | null {
if (!number || !date) return null
const day = date.slice(0, 10)
return ISO_DATE_RE.test(day) ? `${number}::${day}` : null
}
/** A map that remembers keys seen more than once, so those are never joined on. */
function uniqueByKey<T>(rows: T[], keyOf: (row: T) => string | null): Map<string, T> {
const out = new Map<string, T>()
const dupes = new Set<string>()
for (const row of rows) {
const key = keyOf(row)
if (!key) continue
if (out.has(key) || dupes.has(key)) {
out.delete(key)
dupes.add(key)
continue
}
out.set(key, row)
}
return out
}
export async function relinkRegistrationVouchers(
options: RelinkRegistrationVouchersOptions,
): Promise<RelinkRegistrationVouchersResult> {
const { supabase, companyId, consentId, dryRun = false } = options
const resolved = await resolveConsent(companyId, consentId)
const provider = resolved.consent.provider as ProviderName
const [sales, supplier] = await Promise.all([
fetchSalesInvoicesHydrated(provider, resolved.accessToken, resolved.providerCompanyId),
fetchSupplierInvoicesHydrated(provider, resolved.accessToken, resolved.providerCompanyId),
])
const providerSales = sales.invoices
const providerSupplier = supplier.invoices
const [unlinkedSales, unlinkedSupplier] = await Promise.all([
fetchAllRows<UnlinkedSalesRow>(({ from, to }) =>
supabase
.from('invoices')
.select('id, invoice_number, invoice_date, total_sek, currency')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.neq('status', 'draft')
.order('id', { ascending: true })
.range(from, to),
),
fetchAllRows<UnlinkedSupplierRow>(({ from, to }) =>
supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number, invoice_date, total_sek, currency')
.eq('company_id', companyId)
.is('registration_journal_entry_id', null)
.order('id', { ascending: true })
.range(from, to),
),
])
const salesByKey = uniqueByKey(unlinkedSales, (row) => joinKey(row.invoice_number, row.invoice_date))
const supplierByKey = uniqueByKey(unlinkedSupplier, (row) =>
joinKey(row.supplier_invoice_number, row.invoice_date),
)
const providerSalesUnique = uniqueByKey(providerSales, (dto) => joinKey(dto.invoiceNumber, dto.issueDate))
const providerSupplierUnique = uniqueByKey(providerSupplier, (dto) =>
joinKey(dto.invoiceNumber, dto.issueDate),
)
const inputs: MigratedInvoiceLinkInput[] = []
for (const [key, dto] of providerSalesUnique) {
const row = salesByKey.get(key)
if (!row) continue
inputs.push({
invoiceId: row.id,
kind: 'customer',
sourceVoucher: dto.sourceVoucher ?? null,
refNotFetched: sales.unhydratedIds.has(dto.id),
invoiceDate: row.invoice_date,
totalSek: row.total_sek,
currencyCode: row.currency,
invoiceNumber: dto.invoiceNumber || null,
})
}
for (const [key, dto] of providerSupplierUnique) {
const row = supplierByKey.get(key)
if (!row) continue
inputs.push({
invoiceId: row.id,
kind: 'supplier',
sourceVoucher: dto.sourceVoucher ?? null,
refNotFetched: supplier.unhydratedIds.has(dto.id),
invoiceDate: row.invoice_date,
totalSek: row.total_sek,
currencyCode: row.currency,
invoiceNumber: dto.invoiceNumber || null,
})
}
const links = await linkMigratedRegistrationVouchers({ supabase, companyId, invoices: inputs, dryRun })
const providerInvoices = providerSales.length + providerSupplier.length
return {
...links,
providerInvoices,
matched: inputs.length,
unmatched: providerInvoices - inputs.length,
hydration: { sales: sales.hydration, supplier: supplier.hydration },
}
}
+19 -1
View File
@@ -76,7 +76,7 @@ export interface SkipReasons {
* failure classifies, otherwise a generic sentence with the provider's reply).
*/
export interface MigrationStepError {
step: 'companyInfo' | 'customers' | 'suppliers' | 'salesInvoices' | 'supplierInvoices' | 'reconciliation'
step: 'companyInfo' | 'customers' | 'suppliers' | 'salesInvoices' | 'supplierInvoices' | 'registrationLinks' | 'reconciliation'
/** Structured code when the failure classifies (e.g. PROVIDER_API_MODULE_INACTIVE), else null. */
code: string | null
message: string
@@ -117,6 +117,24 @@ export interface MigrationResults {
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
salesInvoices?: InvoiceStepResult
supplierInvoices?: InvoiceStepResult
/**
* Imported invoices linked to the SIE-imported verifikat that BOOKED them
* (the registration voucher the provider named on the invoice). Only exact,
* amount-corroborated matches are written; the other buckets stay unlinked
* and explain why. `refNotFetched` counts invoices whose provider detail
* payload (where Fortnox carries the ref) was never fetched, so nothing is
* known either way. See lib/invoices/link-migrated-registration-vouchers.ts.
*/
registrationLinks?: {
scanned: number
linked: number
noRef: number
refNotFetched: number
unresolved: number
ambiguous: number
amountMismatch: number
alreadyLinked: number
}
/**
* Auto-reconciliation of imported supplier invoices to the GL payment
* vouchers that the separate SIE import already posted. `autoLinked` invoices
@@ -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,
};
+3
View File
@@ -5679,6 +5679,9 @@
"ext_email_description": "Send invoices and reminders via email",
"ext_email_long_description": "Enables email features: send invoices to customers, automatic payment reminders on your chosen schedule, and email notifications. Requires a Resend account with a verified domain.",
"ext_arcim_migration_name": "System migration",
"ext_arcim_registration_links_label": "Voucher links",
"ext_arcim_registration_links_value": "{linked} of {scanned} invoices linked to their booking voucher",
"ext_arcim_registration_links_detail": "{unlinked} not linked: {noRef} without a voucher number at the provider, {refNotFetched} whose provider details could not be fetched in time, {unresolved} without an unambiguous booking voucher, {amountMismatch} with a differing amount",
"ext_arcim_migration_description": "Migrate bookkeeping from Fortnox, Visma, Bokio, Björn Lundén or Briox",
"ext_arcim_migration_long_description": "Move all bookkeeping data from your old system to accounted. Imports chart of accounts, vouchers, customers, suppliers and open invoices automatically via a secure API integration directly with the provider.",
"ext_arcim_bokio_token_description": "Enter the integration token and company ID from Bokio to let {appName} read your bookkeeping data.",
+3
View File
@@ -5679,6 +5679,9 @@
"ext_email_description": "Skicka fakturor och påminnelser via e-post",
"ext_email_long_description": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
"ext_arcim_migration_name": "Systemmigration",
"ext_arcim_registration_links_label": "Verifikatkoppling",
"ext_arcim_registration_links_value": "{linked} av {scanned} fakturor kopplade till bokföringsverifikat",
"ext_arcim_registration_links_detail": "{unlinked} utan koppling: {noRef} saknar verifikatnummer hos leverantören, {refNotFetched} vars detaljer inte hann hämtas från leverantören, {unresolved} utan entydigt bokföringsverifikat, {amountMismatch} med avvikande belopp",
"ext_arcim_migration_description": "Migrera bokföring från Fortnox, Visma, Bokio, Björn Lundén eller Briox",
"ext_arcim_migration_long_description": "Flytta all bokföringsdata från ditt gamla system till accounted. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration direkt med leverantören.",
"ext_arcim_bokio_token_description": "Ange integrationstoken och företags-ID från Bokio för att ge {appName} tillgång att läsa din bokföringsdata.",