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>
This commit is contained in:
Jakob Wennberg
2026-09-05 10:20:10 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 0bd3c27fba
commit 8e1f9d5201
14 changed files with 1291 additions and 2 deletions
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchSalesInvoicesHydrated } from '../provider-data-fetcher';
import { fetchSalesInvoicesDirect, fetchSalesInvoicesHydrated, hydrateSalesInvoices } from '../provider-data-fetcher';
/**
* Hydration is what makes the VAT fix work in production: the list payload
@@ -206,3 +206,63 @@ describe('fetchSalesInvoicesHydrated: detail id comes from the configured idFiel
expect(invoices[0]?.taxTotal?.taxAmount.value).toBe(250);
});
});
describe('hydrateSalesInvoices: a caller-chosen subset of an already-listed register', () => {
let requested: string[];
beforeEach(() => {
requested = [];
vi.stubEnv('UPSTASH_REDIS_REST_URL', '');
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', '');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
const json = (body: unknown) =>
new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
it('requests the detail form only for the invoices it was given', async () => {
// A follow-up pass knows which invoices are still incomplete on its own
// side. Re-hydrating the whole register would spend every run on the
// same open invoices first and never reach the rest.
const third = { ...PAID, DocumentNumber: 6, InvoiceDate: '2025-11-03' };
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
requested.push(url);
if (url.includes('/invoices/5')) return json(detailFor(5, 500));
if (url.includes('/invoices/6')) return json(detailFor(6, 500));
return json(listResponse([OPEN, PAID, third]));
}));
const listed = await fetchSalesInvoicesDirect('fortnox', 'token');
const subset = listed.filter((dto) => dto.id !== '4');
const { invoices, hydration, unhydratedIds } = await hydrateSalesInvoices('fortnox', 'token', undefined, subset);
expect(hydration).toMatchObject({ needed: 2, hydrated: 2, failed: 0, skippedForBudget: 0 });
expect(unhydratedIds.size).toBe(0);
// Same order as given, so the caller can pair results back by index.
expect(invoices.map((dto) => dto.id)).toEqual(['5', '6']);
expect(invoices.every((dto) => dto.lines.length === 1)).toBe(true);
const details = requested.filter((u) => /\/invoices\/\d/.test(u));
expect(details).toHaveLength(2);
expect(details.some((u) => u.includes('/invoices/4'))).toBe(false);
});
it('reports the subset it could not reach, in the caller\'s ids', async () => {
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
requested.push(url);
return json(listResponse([OPEN, PAID]));
}));
const listed = await fetchSalesInvoicesDirect('fortnox', 'token');
const { invoices, hydration, unhydratedIds } = await hydrateSalesInvoices('fortnox', 'token', undefined, listed, 0);
expect(hydration).toMatchObject({ needed: 2, hydrated: 0, skippedForBudget: 2 });
expect([...unhydratedIds].sort()).toEqual(['4', '5']);
expect(invoices).toHaveLength(2);
});
});
+24
View File
@@ -746,7 +746,31 @@ export async function fetchSalesInvoicesHydrated(
budgetMs: number = DEFAULT_HYDRATION_BUDGET_MS,
): Promise<HydratedInvoices<SalesInvoiceDto>> {
const invoices = await fetchSalesInvoicesDirect(provider, accessToken, providerCompanyId);
return hydrateSalesInvoices(provider, accessToken, providerCompanyId, invoices, budgetMs);
}
/**
* Hydrate a caller-chosen set of already-listed sales invoices.
*
* The migration's own pass (above) spends its budget on the WHOLE register,
* open invoices first, and reports what it did not reach. A follow-up that
* wants to finish the job must not repeat that: re-hydrating the register
* from the top would spend every run on the same open invoices and never get
* to the ones still missing their rows. This entry point takes the subset the
* caller already knows to be incomplete on its own side, so each run makes
* progress on exactly those. Invoices that need nothing (a list payload that
* carried its rows) pass through unrequested, as in the full pass.
*
* Returns the invoices in the order given; see `hydrateInvoices` for the
* report and `unhydratedIds` semantics.
*/
export async function hydrateSalesInvoices(
provider: ProviderName,
accessToken: string,
providerCompanyId: string | undefined,
invoices: SalesInvoiceDto[],
budgetMs: number = DEFAULT_HYDRATION_BUDGET_MS,
): Promise<HydratedInvoices<SalesInvoiceDto>> {
const { items, report, unhydratedIds } = await hydrateInvoices<SalesInvoiceDto>(
invoices,
salesInvoiceNeedsDetail,