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

Visma and Fortnox migrations now carry each invoice's source voucher reference, and after the invoice steps a core linker resolves it against the SIE-imported ledger (voucher-ref resolver by date, corroborated by the 244x credit / 151x debit amount, posted only, unreferenced only) and writes registration_journal_entry_id / journal_entry_id. Anything ambiguous, mismatched or unresolved is reported and left NULL; journal entries are never written. The arcim-migration /reconcile endpoint can relink already-migrated companies. Payment vouchers are PR B. Refs #1463
This commit is contained in:
Jakob Wennberg
2026-08-30 11:52:14 +02:00
committed by GitHub
parent 683314b439
commit 521f437072
22 changed files with 2411 additions and 21 deletions
@@ -0,0 +1,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