fix(providers): map Fortnox VAT-inclusive rows net of VAT, and refuse migrated rows that contradict their header (#2302)

The first production run of the row-completion pass (#2291) wrote 345
Profilio invoices whose rows summed to the invoice GROSS with 25 % VAT
computed on top, beside a header (Net / TotalVAT) that was right. Fortnox
prices an invoice either excluding or including VAT and says which with
the invoice-level VATIncluded flag; the mapper had always read the row
Total and Price as net. Every such row set is 1.25 x its header net, to
the öre, across all 345.

- lib/providers/fortnox/mapper.ts: netOfVat() divides row Total and Price
  by (1 + rate) when VATIncluded is true; TotalExcludingVAT and
  PriceExcludingVAT are preferred when the payload carries them. A row
  without a rate cannot be split and keeps its amount.
- complete-invoice-lines.ts: rows whose net or VAT disagree with the header
  the same payload established by more than 1 kr are reported as
  rowsMismatch and left untouched. Öresavrundning stays inside the
  tolerance; VAT-inside rows, header-level freight and discounts do not.
  Rows that contradict their own header are worse than no rows.
- Cron summary carries rowsMismatch.

None of the 345 is open or booked; a separate repair removes today's rows
for them so the fixed pass refills them.


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:53:48 +02:00
committed by GitHub
parent 7c36d471b5
commit e80ea74e76
7 changed files with 184 additions and 5 deletions
+1
View File
@@ -1589,3 +1589,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-04] Sandbox seed recovery (#2292) uses a per-anonymous-user database claim and a completion marker written last. A failed or expired attempt archives its exclusively owned demo company and starts a fresh one, preserving partial posted history for normal sandbox expiry instead of reseeding into it. Legacy demos are adopted only when the final payroll links prove completion. All demo vouchers now use the bookkeeping engine; the posting-integrity guards stay unchanged.
[2026-09-04] Migrated sales invoices without rows (Profilio 384/384, Loftux 311/672, Damac 182/542, Clearstoq 1 125/1 125): completed by an hourly re-runnable pass (extensions/general/arcim-migration/lib/complete-invoice-lines.ts, cron /api/extensions/arcim-migration/complete-invoice-lines/cron) that starts from OUR row-less invoices, joins them to the provider register on number + date, hydrates only that subset and writes 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 0 kr VAT beside subtotal = total). Why not a bigger in-run budget: the largest register (1 911 invoices at Fortnox's platform-wide 4 req/s) does not fit one 300 s function whatever the split, and a budget-bounded one-shot pass leaves whatever it misses missing forever, silently (the wizard never showed the hydration report; it does now). Why not re-running fetchSalesInvoicesHydrated: it sorts the whole register open-first every time, so a second run re-spends its budget on the same invoices and never reaches the rest. Why not reset + re-import or an arithmetic backfill: reset deletes rows that payments and vouchers already point at, and total/1,25 asserts a rate the source never stated (DECISIONS 2026-08-22). The pass reuses mapSalesInvoice, so a row it writes is indistinguishable from a fully hydrated import; it never touches totals, status, payments or any journal entry (momsdeklaration and every report read the ledger).
[2026-09-04] Connector-hop failures (timeout, error envelope, wire-contract mismatch) are transient in every sync path: the row keeps its status and the user message says no renewal is needed, same as AspspUnavailableError (#2202), and the cron now treats AspspUnavailableError the same way instead of parking it in 'error'. Why: on 2026-09-04 the Connect service answered a shape the client rejects and the cron flipped four canary companies to 'error' with SYNC_FAILED_MESSAGE, so users re-authorized consents that were fine. The Zod issues are logged (field paths) because a bare 'unexpected shape' left the failure undiagnosable. Rejected: a new 'degraded' connection status (one more state every filter and the probe would have to learn; the health probe already catches a dead session on the same run) and removing the canary companies from the env (hides the contract bug instead of exposing its field paths).
[2026-09-05] Fortnox VAT-inclusive invoices (VATIncluded: true) now map their rows net of VAT (lib/providers/fortnox/mapper.ts netOfVat, preferring TotalExcludingVAT / PriceExcludingVAT when the payload carries them), and the migrated-row completion pass refuses a row set whose net or VAT disagrees with the header the same payload established by more than 1 kr (rowsMismatch, reported, never stored). Why: the first production run of the completion pass (#2291) wrote 345 Profilio invoices whose rows summed to the gross with 25 % on top, beside a correct header; the mapper had always read row Total as net, and the pass's only cross-check was the invoice total, which the header satisfied. Rows that contradict their own header are worse than no rows: the invoice page shows both, and for an open invoice the booking engine sums the rows. Rejected: comparing against the stored header (it may itself be the pre-#1745 default) and a wider tolerance (öresavrundning is at most 0.50 kr; the real disagreements are kronor).
@@ -44,7 +44,7 @@ const mockComplete = vi.mocked(completeMigratedInvoiceLines)
const EMPTY = {
candidates: 0, providerInvoices: 0, matched: 0, unmatched: 0, completed: 0, headersUpdated: 0,
totalMismatch: 0, noLinesAtProvider: 0, notHydrated: 0, vatUnresolved: 0, failed: 0, remaining: 0,
totalMismatch: 0, noLinesAtProvider: 0, rowsMismatch: 0, notHydrated: 0, vatUnresolved: 0, failed: 0, remaining: 0,
hydration: { needed: 0, hydrated: 0, failed: 0, skippedForBudget: 0 }, dryRun: false,
}
@@ -116,6 +116,7 @@ export const GET = withCronContext('cron.arcim_migration_complete_invoice_lines'
remaining: 0,
notHydrated: 0,
totalMismatch: 0,
rowsMismatch: 0,
failed: 0,
}
@@ -141,6 +142,7 @@ export const GET = withCronContext('cron.arcim_migration_complete_invoice_lines'
totals.remaining += result.remaining
totals.notHydrated += result.notHydrated
totals.totalMismatch += result.totalMismatch
totals.rowsMismatch += result.rowsMismatch
totals.failed += result.failed
itemCtx.log.info('migrated invoice rows completed for company', {
companyId: consent.company_id,
@@ -151,6 +153,7 @@ export const GET = withCronContext('cron.arcim_migration_complete_invoice_lines'
remaining: result.remaining,
notHydrated: result.notHydrated,
totalMismatch: result.totalMismatch,
rowsMismatch: result.rowsMismatch,
hydration: result.hydration,
})
}
@@ -239,6 +239,45 @@ describe('completeMigratedInvoiceLines', () => {
expect(headerUpdates(calls)[0]).toMatchObject({ subtotal: 1000, subtotal_sek: 11200, vat_amount: 250, vat_amount_sek: 2800 })
})
it('refuses rows that do not add up to the header the same payload established', async () => {
// The shape Profilio's 345 invoices took: rows priced with VAT inside,
// summing to the gross, beside a header that was right. Storing them
// would put a row list under the invoice that contradicts its totals.
mFetchAll.mockResolvedValue([storedRow()])
const dto = providerInvoice({
lines: [
{ id: '1', description: 'Mugg', quantity: 1, unitPrice: amount(1250), lineExtensionAmount: amount(1250), taxPercent: 25 },
],
})
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, rowsMismatch: 1, remaining: 1 })
expect(insertedRows(calls)).toHaveLength(0)
expect(headerUpdates(calls)).toHaveLength(0)
})
it('tolerates öresavrundning between the rows and the header', async () => {
// Fortnox rounds Total to whole kronor; the header net absorbs the öre.
mFetchAll.mockResolvedValue([storedRow({ total: 12263 })])
const dto = providerInvoice({
lines: [{ id: '1', description: 'Konsult', quantity: 1, unitPrice: amount(9810), lineExtensionAmount: amount(9810), taxPercent: 25 }],
taxTotal: { taxAmount: amount(2452.5) },
legalMonetaryTotal: { lineExtensionAmount: amount(9810.5), taxInclusiveAmount: amount(12263), payableAmount: amount(12263) },
})
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, rowsMismatch: 0 })
expect(insertedRows(calls)).toHaveLength(1)
})
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()
@@ -76,6 +76,12 @@ export interface CompleteInvoiceLinesResult {
totalMismatch: number
/** Matched and hydrated, but the detail form itself carries no rows. */
noLinesAtProvider: number
/**
* Matched and hydrated, but the mapped rows do not add up to the header the
* same payload established (net or VAT off by more than ROWS_TOLERANCE_KR);
* left untouched rather than stored as rows that contradict their invoice.
*/
rowsMismatch: number
/** Matched but not hydrated this run (budget, auth, or a failed fetch); the next run retries them. */
notHydrated: number
/** Rows written but the header left alone because the detail form established no VAT. */
@@ -106,6 +112,17 @@ interface CandidateRow {
/** Invoices per statement. Small enough that a chunk's rows stay one request. */
const WRITE_CHUNK_SIZE = 100
/**
* How far the rows may disagree with the header before the invoice is left
* alone. Öresavrundning puts up to 0.50 kr between a Fortnox `Total` and the
* unrounded net plus VAT, and per-row VAT rounding adds öre per row; a real
* disagreement (rows priced with VAT inside, a header-level freight or
* discount the rows do not carry) is kronor, not öre. A row set that fails
* this is a mapper or payload problem to be understood, not stored: rows that
* contradict their own header are worse than no rows.
*/
const ROWS_TOLERANCE_KR = 1
/**
* "number::YYYY-MM-DD", the same key the registration-voucher relink joins
* on. A date that does not start like an ISO date joins nothing.
@@ -218,6 +235,7 @@ export async function completeMigratedInvoiceLines(
headersUpdated: 0,
totalMismatch: 0,
noLinesAtProvider: 0,
rowsMismatch: 0,
notHydrated: 0,
vatUnresolved: 0,
failed: 0,
@@ -292,6 +310,21 @@ export async function completeMigratedInvoiceLines(
continue
}
if (!mapped.vatUnresolved) {
const rowsNet = mapped.items.reduce((sum, item) => sum + Number(item.line_total ?? 0), 0)
const rowsVat = mapped.items.reduce((sum, item) => sum + Number(item.vat_amount ?? 0), 0)
const headerNet = mapped.invoice.subtotal as number
const headerVat = mapped.invoice.vat_amount as number
if (Math.abs(rowsNet - headerNet) > ROWS_TOLERANCE_KR || Math.abs(rowsVat - headerVat) > ROWS_TOLERANCE_KR) {
result.rowsMismatch++
log.warn('mapped rows do not add up to the header; invoice left untouched', {
companyId, invoiceId: row.id, invoiceNumber: row.invoice_number,
headerNet, rowsNet: roundOre(rowsNet), headerVat, rowsVat: roundOre(rowsVat), rows: mapped.items.length,
})
continue
}
}
let header: HeaderFill | null = null
if (mapped.vatUnresolved) {
result.vatUnresolved++
@@ -371,6 +404,7 @@ export async function completeMigratedInvoiceLines(
headersUpdated: result.headersUpdated,
notHydrated: result.notHydrated,
totalMismatch: result.totalMismatch,
rowsMismatch: result.rowsMismatch,
failed: result.failed,
remaining: result.remaining,
hydration: result.hydration,
@@ -99,3 +99,73 @@ describe('mapFortnoxToSupplierInvoice: VAT', () => {
expect(detail.taxTotal?.taxAmount.value).toBe(250);
});
});
describe('mapFortnoxToSalesInvoice: VATIncluded rows', () => {
// Profilio (2026-09-05): a 956 kr invoice priced with VAT inside. Its rows
// were stored as if net, summing to 956 with 25 % on top, beside a header
// that read 764.80 + 191.20 correctly.
const inclusive = {
DocumentNumber: 241,
InvoiceDate: '2025-06-02',
Currency: 'SEK',
Total: 956,
Balance: 0,
FullyPaid: true,
Net: 764.8,
TotalVAT: 191.2,
VATIncluded: true,
};
it('converts VAT-inclusive row amounts to net, unit price included', () => {
const dto = mapFortnoxToSalesInvoice({
...inclusive,
InvoiceRows: [
{ RowId: 1, Description: 'Mugg', DeliveredQuantity: 3, Price: 200, Total: 600, VAT: 25 },
{ RowId: 2, Description: 'Frakt', DeliveredQuantity: 1, Price: 356, Total: 356, VAT: 25 },
],
});
expect(dto.lines.map((l) => l.lineExtensionAmount.value)).toEqual([480, 284.8]);
expect(dto.lines.map((l) => l.unitPrice?.value)).toEqual([160, 284.8]);
expect(dto.lines.map((l) => l.taxAmount?.value)).toEqual([120, 71.2]);
const rowsNet = dto.lines.reduce((s, l) => s + l.lineExtensionAmount.value, 0);
expect(rowsNet).toBeCloseTo(764.8, 2);
// The header is unaffected: it always came from Net / TotalVAT.
expect(dto.legalMonetaryTotal.lineExtensionAmount?.value).toBe(764.8);
});
it('prefers the net the row states itself when the payload carries it', () => {
const dto = mapFortnoxToSalesInvoice({
...inclusive,
InvoiceRows: [
{ RowId: 1, Price: 956, PriceExcludingVAT: 764.8, Total: 956, TotalExcludingVAT: 764.8, VAT: 25 },
],
});
expect(dto.lines[0]?.lineExtensionAmount.value).toBe(764.8);
expect(dto.lines[0]?.unitPrice?.value).toBe(764.8);
});
it('leaves rows alone when the invoice is priced excluding VAT', () => {
const dto = mapFortnoxToSalesInvoice({
...inclusive,
VATIncluded: false,
InvoiceRows: [{ RowId: 1, Price: 764.8, Total: 764.8, VAT: 25 }],
});
expect(dto.lines[0]?.lineExtensionAmount.value).toBe(764.8);
expect(dto.lines[0]?.unitPrice?.value).toBe(764.8);
});
it('cannot split a VAT-inclusive row without a rate and keeps the amount', () => {
// Text rows and rows without VAT carry no rate; nothing to divide by. The
// consumer's rows-versus-header check is what reports such an invoice.
const dto = mapFortnoxToSalesInvoice({
...inclusive,
InvoiceRows: [{ RowId: 1, Description: 'Referens', Total: 0 }, { RowId: 2, Total: 956 }],
});
expect(dto.lines.map((l) => l.lineExtensionAmount.value)).toEqual([0, 956]);
expect(dto.lines[1]?.taxAmount).toBeUndefined();
});
});
+36 -4
View File
@@ -10,6 +10,29 @@ import type {
} from '../dto';
import { readNumber, resolveVatTriple, lineVatFromPercent } from '../amounts';
import { sourceVoucherFromParts } from '../source-voucher';
import { roundOre } from '@/lib/money';
/**
* A row amount net of VAT.
*
* Fortnox prices an invoice either excluding or including VAT, and says which
* with the invoice-level `VATIncluded` flag: when it is true the row `Price`
* and `Total` are the amounts the customer saw, VAT inside, and the net is
* the amount divided by (1 + rate). Newer payloads also carry the net on the
* row (`TotalExcludingVAT`, `PriceExcludingVAT`), which callers prefer when
* present; this is the fallback for the ones that do not. Without a stated
* rate the amount cannot be split and is returned as it is, and the
* consumer's rows-versus-header check reports the disagreement.
*
* Found on Profilio (2026-09-05): 345 VAT-inclusive invoices whose rows were
* stored as if net, so the rows summed to the gross and carried 25 % VAT on
* top of it, beside a header that was right.
*/
function netOfVat(amount: number, vatIncluded: boolean, ratePercent: number | undefined): number {
if (!vatIncluded || ratePercent === undefined) return amount;
const rate = ratePercent > 1 ? ratePercent / 100 : ratePercent;
return roundOre(amount / (1 + rate));
}
/**
* Fortnox splits its invoice payloads in two. `GET /3/invoices` answers with
@@ -118,12 +141,21 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
const paid = isFullyPaid(raw);
const balance = paid ? 0 : ((raw['Balance'] as number | undefined) ?? total);
// Whether the row amounts include VAT. Absent on the list form, where there
// are no rows anyway; false is the default when the detail form omits it.
const vatIncluded = raw['VATIncluded'] === true;
const rows = (raw['InvoiceRows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => {
// Fortnox `Total` on a row is the line amount excluding VAT; `VAT` is the
// rate in percent (25), not an amount.
const lineNet = readNumber(row, ['Total']) ?? 0;
// `VAT` on a row is the rate in percent (25), not an amount. `Total` and
// `Price` are net only when the invoice is priced excluding VAT; see
// netOfVat for the VATIncluded case.
const taxPercent = readNumber(row, ['VAT']);
const lineNet = readNumber(row, ['TotalExcludingVAT'])
?? netOfVat(readNumber(row, ['Total']) ?? 0, vatIncluded, taxPercent);
const rawPrice = readNumber(row, ['Price']);
const unitPrice = readNumber(row, ['PriceExcludingVAT'])
?? (rawPrice !== undefined ? netOfVat(rawPrice, vatIncluded, taxPercent) : undefined);
const lineVat = lineVatFromPercent(lineNet, taxPercent);
return {
@@ -131,7 +163,7 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
description: row['Description'] as string | undefined,
quantity: row['DeliveredQuantity'] as number | undefined,
unitCode: row['Unit'] as string | undefined,
unitPrice: row['Price'] != null ? amount(row['Price'] as number, currency) : undefined,
unitPrice: unitPrice !== undefined ? amount(unitPrice, currency) : undefined,
lineExtensionAmount: amount(lineNet, currency),
taxPercent,
// Fortnox states the rate per row but not the money. Deriving it here is