Files
accounted/extensions/general/arcim-migration/lib/__tests__/complete-invoice-lines.test.ts
T
8e1f9d5201 fix(migration): complete the rows of migrated sales invoices the hydration budget did not reach (#2291)
* fix(migration): complete the rows of migrated sales invoices the hydration budget did not reach

The migration maps sales invoices from the provider's list payload and
hydrates the detail form (rows, net, VAT) inside a fixed 90 s budget, open
invoices first. Fortnox, Briox and Björn Lundén ship no rows in a list
response, so every invoice the budget did not reach was imported as a header
with a total and no invoice_items, and nothing ever came back for it: the
wizard never showed the hydration report, so the user found out on the
invoice page. Measured on prod today: Profilio 384 of 384 (migrated before
hydration existed), Loftux 311 of 672, Damac 182 of 542, Clearstoq 1 125 of
1 125.

- lib/providers: hydrateSalesInvoices() hydrates a caller-chosen subset of
  an already-listed register, so a follow-up can spend its budget on the
  invoices still incomplete on our side instead of re-walking the register
  open-first and never reaching the rest.
- arcim-migration: completeMigratedInvoiceLines() starts from OUR row-less
  non-draft invoices, joins them to the provider register on number + date
  (unique on both sides), hydrates only that subset and writes each
  invoice's rows once the detail total matches the stored total to the öre.
  The header VAT split is rewritten only when the stored one holds no
  evidence (null rate, or a non-zero rate label beside 0 kr VAT and
  subtotal = total). Never the total, status, payments or a journal entry.
- Hourly cron (/api/extensions/arcim-migration/complete-invoice-lines/cron,
  vercel.json + Docker crontabs) drives the pass over consents accepted in
  the last 60 days, newest first, with a per-company share of the run.
- The wizard's result screen now shows "x av y fakturor hämtade med rader"
  and that the rest are fetched in the background within the hour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DG5aYcshzKJ1EA7PPhGtVf

* fix(migration): write the header VAT fill as a literal, raise the schema-guard ceiling for the row inserts

The phantom-column scanner resolves only object-literal payloads. The header
update is now a literal (so its six columns are checked); the two
invoice_items inserts are runtime row arrays from mapSalesInvoiceLine, the
same shape the orchestrator already inserts, so the ceiling moves 399 to
401 with the reason recorded beside the earlier ones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DG5aYcshzKJ1EA7PPhGtVf

* fix(migration): gate the completion cron on token freshness, not consent age, and visit every usable consent

Two review findings held. Prod holds 57 accepted consents from the last 60
days, so a fixed page of the newest 25 would leave older companies with
row-less invoices waiting behind companies that are already done: the cap
is gone (a company with nothing left costs one query and no provider call).
And the consent's created_at said nothing about whether its credentials
still work: Fortnox refresh tokens live 45 days and rotate on every
refresh, so eligibility is now read off the token row (access token expired
within the last 45 days, or no expiry at all), which also stops a dead
consent from being retried every hour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DG5aYcshzKJ1EA7PPhGtVf

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:20:10 +02:00

371 lines
15 KiB
TypeScript

import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { SalesInvoiceDto } from '@/lib/providers/dto'
/**
* The follow-up pass for migrated sales invoices imported without rows
* (Fortnox, Briox and Björn Lundén ship none in a list payload, and the
* migration's detail hydration is budget-bounded). It must start from OUR
* row-less invoices, join strictly, hydrate only that subset, write rows only
* when the provider's total agrees with the stored one, and leave anything it
* could not reach for the next run rather than guessing.
*/
vi.mock('@/lib/providers/resolve-consent', () => ({
resolveConsent: vi.fn().mockResolvedValue({
consent: { provider: 'fortnox' },
accessToken: 'tok',
providerCompanyId: undefined,
}),
}))
vi.mock('@/lib/providers/provider-data-fetcher', () => ({
fetchSalesInvoicesDirect: vi.fn(),
hydrateSalesInvoices: vi.fn(),
}))
vi.mock('@/lib/supabase/fetch-all', () => ({ fetchAllRows: vi.fn() }))
import { resolveConsent } from '@/lib/providers/resolve-consent'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchSalesInvoicesDirect, hydrateSalesInvoices } from '@/lib/providers/provider-data-fetcher'
import { completeMigratedInvoiceLines } from '../complete-invoice-lines'
const mResolve = resolveConsent as Mock
const mFetchAll = fetchAllRows as Mock
const mList = fetchSalesInvoicesDirect as Mock
const mHydrate = hydrateSalesInvoices as Mock
const HYDRATION = { needed: 0, hydrated: 0, failed: 0, skippedForBudget: 0 }
const amount = (value: number, currencyCode = 'SEK') => ({ value, currencyCode })
/** A hydrated Fortnox-shaped invoice: 1 000 kr net, 25 % VAT, 1 250 kr total. */
function providerInvoice(overrides: Partial<SalesInvoiceDto> = {}): SalesInvoiceDto {
return {
id: '1001',
invoiceNumber: '1001',
issueDate: '2026-03-14',
currencyCode: 'SEK',
status: 'paid',
supplier: { name: 'Profilio Sweden AB', identifications: [] },
customer: { name: 'Kund AB', identifications: [] },
lines: [
{
id: '1',
description: 'Konsulttid',
quantity: 10,
unitCode: 'h',
unitPrice: amount(100),
lineExtensionAmount: amount(1000),
taxPercent: 25,
},
],
taxTotal: { taxAmount: amount(250) },
legalMonetaryTotal: {
lineExtensionAmount: amount(1000),
taxInclusiveAmount: amount(1250),
payableAmount: amount(1250),
},
paymentStatus: { paid: true, balance: amount(0) },
...overrides,
}
}
/** A stored row as the pre-#1745 import left it: 25 % label, 0 kr VAT, no rows. */
function storedRow(overrides: Record<string, unknown> = {}) {
return {
id: 'inv-1',
user_id: 'user-1',
customer_id: 'cust-1',
invoice_number: '1001',
invoice_date: '2026-03-14',
total: 1250,
subtotal: 1250,
vat_amount: 0,
vat_rate: 25,
currency: 'SEK',
exchange_rate: null,
invoice_items: [],
...overrides,
}
}
/** Every hydrated as given, in order, nothing left unhydrated. */
function hydratedAll(invoices: SalesInvoiceDto[], unhydratedIds: string[] = []) {
return {
invoices,
hydration: { ...HYDRATION, needed: invoices.length, hydrated: invoices.length - unhydratedIds.length },
unhydratedIds: new Set(unhydratedIds),
}
}
interface Call { table: string; method: string; args: unknown[] }
/**
* Thenable query-builder stand-in. Records every call; resolves with what
* `respond` returns for the table and the methods used on the chain.
*/
function makeSupabase(respond: (table: string, methods: string[], calls: Call[]) => unknown) {
const calls: Call[] = []
const from = vi.fn((table: string) => {
const chain: Call[] = []
const builder: Record<string, unknown> = {}
for (const method of ['select', 'in', 'insert', 'update', 'eq', 'neq', 'order', 'range']) {
builder[method] = (...args: unknown[]) => {
const call = { table, method, args }
chain.push(call)
calls.push(call)
return builder
}
}
builder.then = (resolve: (v: unknown) => void, reject: (e: unknown) => void) =>
Promise.resolve()
.then(() => respond(table, chain.map((c) => c.method), chain))
.then(resolve, reject)
return builder
})
return { supabase: { from } as unknown as SupabaseClient, calls }
}
const ok = { data: [], error: null }
function insertedRows(calls: Call[]): Record<string, unknown>[] {
return calls
.filter((c) => c.table === 'invoice_items' && c.method === 'insert')
.flatMap((c) => c.args[0] as Record<string, unknown>[])
}
function headerUpdates(calls: Call[]): Record<string, unknown>[] {
return calls
.filter((c) => c.table === 'invoices' && c.method === 'update')
.map((c) => c.args[0] as Record<string, unknown>)
}
describe('completeMigratedInvoiceLines', () => {
beforeEach(() => {
vi.clearAllMocks()
mResolve.mockResolvedValue({ consent: { provider: 'fortnox' }, accessToken: 'tok', providerCompanyId: undefined })
})
it('writes the rows and fills a header that held no VAT evidence', async () => {
mFetchAll.mockResolvedValue([storedRow()])
const dto = providerInvoice()
mList.mockResolvedValue([dto])
mHydrate.mockResolvedValue(hydratedAll([dto]))
const { supabase, calls } = makeSupabase(() => ok)
const result = await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1' })
expect(result).toMatchObject({
candidates: 1, providerInvoices: 1, matched: 1, unmatched: 0,
completed: 1, headersUpdated: 1, remaining: 0, totalMismatch: 0, notHydrated: 0, failed: 0,
})
// Only the matched subset is hydrated, so the budget is never spent on
// invoices already complete on our side.
expect(mHydrate).toHaveBeenCalledTimes(1)
expect(mHydrate.mock.calls[0][3]).toEqual([dto])
const rows = insertedRows(calls)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
invoice_id: 'inv-1',
sort_order: 1,
description: 'Konsulttid',
quantity: 10,
unit: 'h',
unit_price: 100,
line_total: 1000,
vat_rate: 25,
vat_amount: 250,
})
const headers = headerUpdates(calls)
expect(headers).toHaveLength(1)
expect(headers[0]).toEqual({
subtotal: 1000,
subtotal_sek: 1000,
vat_amount: 250,
vat_amount_sek: 250,
vat_rate: 25,
vat_treatment: 'standard_25',
})
// Scoped to the company as well as the id: defense in depth on a
// service-role client.
const update = calls.find((c) => c.table === 'invoices' && c.method === 'update')!
const scope = calls.filter((c) => c.table === 'invoices' && c.method === 'eq' && calls.indexOf(c) > calls.indexOf(update))
expect(scope.map((c) => c.args)).toEqual([['id', 'inv-1'], ['company_id', 'co-1']])
// Never the total, status or payments.
expect(Object.keys(headers[0])).not.toContain('total')
expect(Object.keys(headers[0])).not.toContain('status')
})
it('writes the rows but leaves a header whose split is consistent (momsfri)', async () => {
mFetchAll.mockResolvedValue([storedRow({ vat_rate: 0, vat_amount: 0, subtotal: 1250 })])
const dto = providerInvoice()
mList.mockResolvedValue([dto])
mHydrate.mockResolvedValue(hydratedAll([dto]))
const { supabase, calls } = makeSupabase(() => ok)
const result = await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1' })
expect(result).toMatchObject({ completed: 1, headersUpdated: 0 })
expect(insertedRows(calls)).toHaveLength(1)
expect(headerUpdates(calls)).toHaveLength(0)
})
it('fills a header whose rate is null (the post-#1745 "source did not say")', async () => {
mFetchAll.mockResolvedValue([storedRow({ vat_rate: null, vat_amount: 0, subtotal: 1250 })])
const dto = providerInvoice()
mList.mockResolvedValue([dto])
mHydrate.mockResolvedValue(hydratedAll([dto]))
const { supabase, calls } = makeSupabase(() => ok)
const result = await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1' })
expect(result).toMatchObject({ completed: 1, headersUpdated: 1 })
expect(headerUpdates(calls)[0]).toMatchObject({ vat_rate: 25, vat_amount: 250, subtotal: 1000 })
})
it('derives the SEK twins from the rate the row already carries', async () => {
mFetchAll.mockResolvedValue([storedRow({ currency: 'EUR', exchange_rate: 11.2, total: 1250 })])
const dto = providerInvoice({ currencyCode: 'EUR' })
mList.mockResolvedValue([dto])
mHydrate.mockResolvedValue(hydratedAll([dto]))
const { supabase, calls } = makeSupabase(() => ok)
await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1' })
expect(headerUpdates(calls)[0]).toMatchObject({ subtotal: 1000, subtotal_sek: 11200, vat_amount: 250, vat_amount_sek: 2800 })
})
it('leaves an invoice untouched when the provider total differs from the stored one', async () => {
mFetchAll.mockResolvedValue([storedRow({ total: 1300, subtotal: 1300 })])
const dto = providerInvoice()
mList.mockResolvedValue([dto])
mHydrate.mockResolvedValue(hydratedAll([dto]))
const { supabase, calls } = makeSupabase(() => ok)
const result = await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1' })
expect(result).toMatchObject({ matched: 1, completed: 0, totalMismatch: 1, remaining: 1 })
expect(insertedRows(calls)).toHaveLength(0)
expect(headerUpdates(calls)).toHaveLength(0)
})
it('reverses the rows of a kreditfaktura the way the migration does', async () => {
mFetchAll.mockResolvedValue([storedRow({ total: -1250, subtotal: -1250 })])
const dto = providerInvoice({ invoiceTypeCode: '381', status: 'credited' })
mList.mockResolvedValue([dto])
mHydrate.mockResolvedValue(hydratedAll([dto]))
const { supabase, calls } = makeSupabase(() => ok)
const result = await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1' })
expect(result).toMatchObject({ completed: 1, totalMismatch: 0 })
expect(insertedRows(calls)[0]).toMatchObject({ quantity: -10, unit_price: 100, line_total: -1000, vat_amount: -250 })
expect(headerUpdates(calls)[0]).toMatchObject({ subtotal: -1000, vat_amount: -250 })
})
it('leaves invoices the budget did not reach for the next run', async () => {
mFetchAll.mockResolvedValue([storedRow(), storedRow({ id: 'inv-2', invoice_number: '1002' })])
const first = providerInvoice()
const second = providerInvoice({ id: '1002', invoiceNumber: '1002', lines: [] })
mList.mockResolvedValue([first, second])
// The second came back in list form only (no rows): the budget ran out.
mHydrate.mockResolvedValue(hydratedAll([first, second], ['1002']))
const { supabase, calls } = makeSupabase(() => ok)
const result = await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1' })
expect(result).toMatchObject({ candidates: 2, matched: 2, completed: 1, notHydrated: 1, remaining: 1 })
expect(insertedRows(calls).map((r) => r.invoice_id)).toEqual(['inv-1'])
})
it('does not join an ambiguous key, and does not hydrate when nothing joined', async () => {
// Two stored rows with the same number and date: a wrong join would put
// one invoice's rows under the other.
mFetchAll.mockResolvedValue([storedRow(), storedRow({ id: 'inv-dup' })])
mList.mockResolvedValue([providerInvoice()])
const { supabase, calls } = makeSupabase(() => ok)
const result = await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1' })
expect(result).toMatchObject({ candidates: 2, matched: 0, unmatched: 2, completed: 0, remaining: 2 })
expect(mHydrate).not.toHaveBeenCalled()
expect(insertedRows(calls)).toHaveLength(0)
})
it('costs one query and no provider call when the company has nothing to complete', async () => {
mFetchAll.mockResolvedValue([storedRow({ invoice_items: [{ id: 'item-1' }] })])
const { supabase, calls } = makeSupabase(() => ok)
const result = await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1' })
expect(result).toMatchObject({ candidates: 0, completed: 0, remaining: 0 })
expect(mResolve).not.toHaveBeenCalled()
expect(mList).not.toHaveBeenCalled()
expect(calls).toHaveLength(0)
})
it('skips an invoice that gained rows since the candidates were loaded', async () => {
mFetchAll.mockResolvedValue([storedRow()])
const dto = providerInvoice()
mList.mockResolvedValue([dto])
mHydrate.mockResolvedValue(hydratedAll([dto]))
const { supabase, calls } = makeSupabase((table, methods) =>
table === 'invoice_items' && methods.includes('in') ? { data: [{ invoice_id: 'inv-1' }], error: null } : ok,
)
const result = await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1' })
expect(result).toMatchObject({ completed: 0, failed: 0, remaining: 1 })
expect(insertedRows(calls)).toHaveLength(0)
expect(headerUpdates(calls)).toHaveLength(0)
})
it('retries per invoice when the batch insert fails, and counts the offender', async () => {
mFetchAll.mockResolvedValue([storedRow(), storedRow({ id: 'inv-2', invoice_number: '1002' })])
const first = providerInvoice()
const second = providerInvoice({ id: '1002', invoiceNumber: '1002' })
mList.mockResolvedValue([first, second])
mHydrate.mockResolvedValue(hydratedAll([first, second]))
const { supabase, calls } = makeSupabase((table, methods, chain) => {
if (table !== 'invoice_items' || !methods.includes('insert')) return ok
const rows = chain[0].args[0] as { invoice_id: string }[]
if (rows.length > 1) return { data: null, error: { message: 'batch rejected' } }
return rows[0].invoice_id === 'inv-2' ? { data: null, error: { message: 'check violation' } } : ok
})
const result = await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1' })
expect(result).toMatchObject({ completed: 1, failed: 1, headersUpdated: 1, remaining: 1 })
expect(headerUpdates(calls)).toHaveLength(1)
})
it('dry run: reports the plan and writes nothing', async () => {
mFetchAll.mockResolvedValue([storedRow()])
const dto = providerInvoice()
mList.mockResolvedValue([dto])
mHydrate.mockResolvedValue(hydratedAll([dto]))
const { supabase, calls } = makeSupabase(() => ok)
const result = await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1', dryRun: true })
expect(result).toMatchObject({ dryRun: true, completed: 1, headersUpdated: 1, remaining: 0 })
expect(calls).toHaveLength(0)
})
it('passes the budget through to the hydration', async () => {
mFetchAll.mockResolvedValue([storedRow()])
const dto = providerInvoice()
mList.mockResolvedValue([dto])
mHydrate.mockResolvedValue(hydratedAll([dto]))
const { supabase } = makeSupabase(() => ok)
await completeMigratedInvoiceLines({ supabase, companyId: 'co-1', consentId: 'c-1', budgetMs: 45_000 })
expect(mHydrate.mock.calls[0][4]).toBe(45_000)
})
})