Visma and Fortnox migrations now carry each invoice's source voucher reference, and after the invoice steps a core linker resolves it against the SIE-imported ledger (voucher-ref resolver by date, corroborated by the 244x credit / 151x debit amount, posted only, unreferenced only) and writes registration_journal_entry_id / journal_entry_id. Anything ambiguous, mismatched or unresolved is reported and left NULL; journal entries are never written. The arcim-migration /reconcile endpoint can relink already-migrated companies. Payment vouchers are PR B. Refs #1463
This commit is contained in:
@@ -0,0 +1,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')
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
+218
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user