fix(providers): Fortnox freight and fee as rows, text rows as text, string quantities as numbers (#2304)
* fix(providers): Fortnox freight and fee as rows, text rows as text, string quantities as numbers Three shapes seen on live Profilio payloads after #2302's rows-versus-header check went in: - Freight and AdministrationFee live on the invoice header, not in InvoiceRows, while Total and TotalVAT include them. The rows summed to less than the header by exactly the charge and the check refused the invoice (14 of Profilio's 384). They are now rows: FreightVAT and AdministrationFeeVAT are VAT amounts (88 and 22 on a 25 % invoice), and the charge is gross when VATIncluded is true (99 = 79.20 + 19.80). - Free-text rows (DeliveredQuantity "0", Total 0, VAT 0) counted as a stated 0 % rate beside the 25 % rows, so the migration marked the invoice mixed and nulled its header rate on roughly half of two registers. They no longer state a rate, and land as line_type 'text' with no amounts, the way the invoice page and the booking engine expect them. - DeliveredQuantity is serialised as a string and was stored unparsed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DG5aYcshzKJ1EA7PPhGtVf * fix(migration): type the text-row check so resolveInvoiceVat's line shape accepts it 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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
e80ea74e76
commit
34bf5a7387
@@ -1590,3 +1590,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[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).
|
||||
[2026-09-05] Fortnox header-level Freight and AdministrationFee become synthetic rows in the sales mapper (lib/providers/fortnox/mapper.ts headerChargeLines), free-text rows (no quantity, no amount) land as line_type 'text' and no longer count as a stated 0 % rate in the migration's VAT resolver. Why: Fortnox keeps both charges outside InvoiceRows while Total and TotalVAT include them, so the rows summed to less than the header by exactly the charge, and after #2302 the rows-versus-header check refused those invoices (Profilio 14 of 384); the *VAT fields are amounts, not rates, and the charge is gross when VATIncluded, all verified on live payloads (invoices 295 and 242) rather than the spec, whose endpoint answered 429 all day. Text rows with VAT 0 beside 25 % rows had made roughly half of the Loftux and Clearstoq registers "mixed" with a null header rate. Rejected: dropping the charge into the first priced row (it is its own line on the customer's invoice, often on 3520) and trusting Net for the header (Net excludes the charges; gross minus TotalVAT is the net the rows must reach).
|
||||
|
||||
@@ -302,3 +302,61 @@ describe('end to end: the reported Fortnox invoice', () => {
|
||||
expect((invoice.subtotal as number) + (invoice.vat_amount as number)).toBe(invoice.total)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapSalesInvoice: free-text rows', () => {
|
||||
const sek = (value: number) => ({ value, currencyCode: 'SEK' })
|
||||
|
||||
it('does not let a text row state a 0 % rate beside the priced rows', () => {
|
||||
// Fortnox ships "5 st M, 8 st L" as a row with Total 0 and VAT 0. Counting
|
||||
// that as a stated rate made the invoice "mixed" and nulled the header
|
||||
// rate on roughly half of the Loftux and Clearstoq registers.
|
||||
const { invoice } = mapSalesInvoice(
|
||||
salesDto({
|
||||
lines: [
|
||||
{ id: '687', description: 'Hoodie', quantity: 14, unitPrice: sek(359.2), lineExtensionAmount: sek(5028.8), taxPercent: 25, taxAmount: sek(1257.2) },
|
||||
{ id: '688', description: '5 st M, 8 st L, 1 st XL', quantity: 0, unitPrice: sek(0), lineExtensionAmount: sek(0), taxPercent: 0, taxAmount: sek(0) },
|
||||
],
|
||||
taxTotal: { taxAmount: sek(1257.2) },
|
||||
legalMonetaryTotal: { lineExtensionAmount: sek(5028.8), payableAmount: sek(6286) },
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(invoice.vat_rate).toBe(25)
|
||||
expect(invoice.vat_treatment).toBe('standard_25')
|
||||
})
|
||||
|
||||
it('lands a text row as line_type text with no amounts, and a priced row as product', () => {
|
||||
const { items } = mapSalesInvoice(
|
||||
salesDto({
|
||||
lines: [
|
||||
{ id: '687', description: 'Hoodie', quantity: 14, unitPrice: sek(359.2), lineExtensionAmount: sek(5028.8), taxPercent: 25, taxAmount: sek(1257.2) },
|
||||
{ id: '688', description: '5 st M, 8 st L, 1 st XL', quantity: 0, unitPrice: sek(0), lineExtensionAmount: sek(0), taxPercent: 0, taxAmount: sek(0) },
|
||||
],
|
||||
taxTotal: { taxAmount: sek(1257.2) },
|
||||
legalMonetaryTotal: { lineExtensionAmount: sek(5028.8), payableAmount: sek(6286) },
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(items[0]).toMatchObject({ line_type: 'product', quantity: 14, line_total: 5028.8, vat_rate: 25 })
|
||||
expect(items[1]).toMatchObject({ line_type: 'text', description: '5 st M, 8 st L, 1 st XL', quantity: 0, unit_price: 0, line_total: 0, vat_amount: 0 })
|
||||
})
|
||||
|
||||
it('still reports a genuinely mixed invoice as mixed', () => {
|
||||
const { invoice } = mapSalesInvoice(
|
||||
salesDto({
|
||||
lines: [
|
||||
{ id: '1', quantity: 1, unitPrice: sek(1000), lineExtensionAmount: sek(1000), taxPercent: 25, taxAmount: sek(250) },
|
||||
{ id: '2', quantity: 1, unitPrice: sek(100), lineExtensionAmount: sek(100), taxPercent: 6, taxAmount: sek(6) },
|
||||
],
|
||||
taxTotal: { taxAmount: sek(256) },
|
||||
legalMonetaryTotal: { lineExtensionAmount: sek(1100), payableAmount: sek(1356) },
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(invoice.vat_rate).toBeNull()
|
||||
expect(invoice.vat_treatment).toBe('standard_25')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -291,8 +291,24 @@ function treatmentForRate(rate: number, currencyCode?: string): VatTreatment {
|
||||
* and keeps only the figure that IS known, the gross the customer owes; the
|
||||
* rate goes to null so no downstream reader can mistake silence for 25 %.
|
||||
*/
|
||||
/**
|
||||
* A free-text row: no quantity, no amount, no unit price. Providers ship
|
||||
* these as ordinary rows (Fortnox: DeliveredQuantity "0", Total 0, VAT 0);
|
||||
* Accounted models them as `line_type = 'text'`, which the invoice page
|
||||
* renders without amounts and the booking engine leaves out.
|
||||
*/
|
||||
interface LineShape {
|
||||
quantity?: number
|
||||
unitPrice?: { value: number }
|
||||
lineExtensionAmount?: { value: number }
|
||||
}
|
||||
|
||||
function isTextLine(line: LineShape): boolean {
|
||||
return !line.quantity && !line.unitPrice?.value && line.lineExtensionAmount?.value === 0
|
||||
}
|
||||
|
||||
function resolveInvoiceVat(
|
||||
dto: { currencyCode: string; lines: readonly { taxPercent?: number; taxAmount?: { value: number } }[]; taxTotal?: { taxAmount: { value: number } }; legalMonetaryTotal: { lineExtensionAmount?: { value: number }; payableAmount: { value: number } } },
|
||||
dto: { currencyCode: string; lines: readonly (LineShape & { taxPercent?: number; taxAmount?: { value: number } })[]; taxTotal?: { taxAmount: { value: number } }; legalMonetaryTotal: { lineExtensionAmount?: { value: number }; payableAmount: { value: number } } },
|
||||
): InvoiceVatResolution {
|
||||
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
|
||||
const statedNet = dto.legalMonetaryTotal.lineExtensionAmount?.value
|
||||
@@ -324,9 +340,13 @@ function resolveInvoiceVat(
|
||||
// one divided out of the totals, because a mixed-rate invoice divides out to
|
||||
// a blended figure matching no statutory rate at all (25 % goods plus 6 %
|
||||
// books lands near 21 %).
|
||||
// A row that carries no money states no rate: Fortnox ships its free-text
|
||||
// rows ("5 st M, 8 st L") with Total 0 and VAT 0, and counting that 0 %
|
||||
// beside the 25 % of the priced rows made every such invoice "mixed" and
|
||||
// nulled its header rate.
|
||||
const statedRates = [...new Set(
|
||||
dto.lines
|
||||
.filter((line) => line.taxPercent != null)
|
||||
.filter((line) => line.taxPercent != null && !isTextLine(line))
|
||||
.map((line) => snapToSwedishRate(line.taxPercent as number) ?? (line.taxPercent as number)),
|
||||
)]
|
||||
|
||||
@@ -828,6 +848,20 @@ function mapSalesInvoiceLine(
|
||||
// credit note issued in-app (lib/invoices/build-credit-note-item.ts).
|
||||
const sign = (n: number): number => (isCreditNote ? negate(n) : n)
|
||||
|
||||
if (isTextLine(line)) {
|
||||
return {
|
||||
sort_order: index + 1,
|
||||
description: line.description || line.itemName || '',
|
||||
quantity: 0,
|
||||
unit: line.unitCode || 'st',
|
||||
unit_price: 0,
|
||||
line_total: 0,
|
||||
vat_rate: 0,
|
||||
vat_amount: 0,
|
||||
line_type: 'text',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sort_order: index + 1,
|
||||
description: line.description || line.itemName || '',
|
||||
@@ -839,6 +873,7 @@ function mapSalesInvoiceLine(
|
||||
// 0 % line beside 0 kr of VAT is at least internally consistent.
|
||||
vat_rate: rate ?? 0,
|
||||
vat_amount: sign(round2(vatAmount ?? 0)),
|
||||
line_type: 'product',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,3 +169,116 @@ describe('mapFortnoxToSalesInvoice: VATIncluded rows', () => {
|
||||
expect(dto.lines[1]?.taxAmount).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapFortnoxToSalesInvoice: header-level freight and fee', () => {
|
||||
// Live shapes from Profilio (2026-09-05). Fortnox keeps freight and the
|
||||
// administration fee on the header: `Net` excludes them, `Total` and
|
||||
// `TotalVAT` include them, and the *VAT fields are amounts, not rates.
|
||||
const base = {
|
||||
DocumentNumber: 295,
|
||||
InvoiceDate: '2026-04-02',
|
||||
Currency: 'SEK',
|
||||
Balance: 0,
|
||||
FullyPaid: true,
|
||||
AdministrationFee: 0,
|
||||
AdministrationFeeVAT: 0,
|
||||
};
|
||||
|
||||
it('adds a freight row net of VAT on an invoice priced excluding VAT', () => {
|
||||
const dto = mapFortnoxToSalesInvoice({
|
||||
...base,
|
||||
VATIncluded: false,
|
||||
Total: 9504,
|
||||
Net: 7515.2,
|
||||
TotalVAT: 1900.8,
|
||||
Freight: 88,
|
||||
FreightVAT: 22,
|
||||
InvoiceRows: [
|
||||
{ RowId: 687, Description: 'Hoodie', DeliveredQuantity: '14', Price: 359.2, PriceExcludingVAT: 359.2, Total: 5028.8, TotalExcludingVAT: 5028.8, VAT: 25 },
|
||||
{ RowId: 689, Description: 'T-shirt', DeliveredQuantity: '12', Price: 207.2, PriceExcludingVAT: 207.2, Total: 2486.4, TotalExcludingVAT: 2486.4, VAT: 25 },
|
||||
],
|
||||
});
|
||||
|
||||
const freight = dto.lines.at(-1);
|
||||
expect(freight).toMatchObject({ id: 'freight', description: 'Frakt', quantity: 1, taxPercent: 25 });
|
||||
expect(freight?.lineExtensionAmount.value).toBe(88);
|
||||
expect(freight?.taxAmount?.value).toBe(22);
|
||||
// Rows now add up to the header net the migration derives (gross - VAT).
|
||||
const rowsNet = dto.lines.reduce((s, l) => s + l.lineExtensionAmount.value, 0);
|
||||
expect(rowsNet).toBeCloseTo(9504 - 1900.8, 2);
|
||||
});
|
||||
|
||||
it('reads the freight as gross when the invoice is priced including VAT', () => {
|
||||
const dto = mapFortnoxToSalesInvoice({
|
||||
...base,
|
||||
DocumentNumber: 242,
|
||||
VATIncluded: true,
|
||||
Total: 2327.8,
|
||||
Net: 1783.04,
|
||||
TotalVAT: 465.56,
|
||||
Freight: 99,
|
||||
FreightVAT: 19.8,
|
||||
InvoiceRows: [
|
||||
{ RowId: 544, Description: 'Carnegie-tshirts', DeliveredQuantity: '14', Price: 199, PriceExcludingVAT: 159.2, Total: 2228.8, TotalExcludingVAT: 1783.04, VAT: 25, Discount: 20, DiscountType: 'PERCENT' },
|
||||
{ RowId: 548, Description: '20 % rabatt på grund av försenad leverans.', DeliveredQuantity: '0', Price: 0, PriceExcludingVAT: 0, Total: 0, TotalExcludingVAT: 0, VAT: 0 },
|
||||
],
|
||||
});
|
||||
|
||||
const freight = dto.lines.at(-1);
|
||||
expect(freight?.lineExtensionAmount.value).toBe(79.2);
|
||||
expect(freight?.taxAmount?.value).toBe(19.8);
|
||||
expect(freight?.taxPercent).toBe(25);
|
||||
const rowsNet = dto.lines.reduce((s, l) => s + l.lineExtensionAmount.value, 0);
|
||||
expect(rowsNet).toBeCloseTo(2327.8 - 465.56, 2);
|
||||
});
|
||||
|
||||
it('adds no row when the header carries no charge', () => {
|
||||
const dto = mapFortnoxToSalesInvoice({
|
||||
...base,
|
||||
VATIncluded: true,
|
||||
Total: 956,
|
||||
Net: 764.8,
|
||||
TotalVAT: 191.2,
|
||||
Freight: 0,
|
||||
FreightVAT: 0,
|
||||
InvoiceRows: [{ RowId: 541, Description: 'Hoodie', DeliveredQuantity: '1', Price: 419, PriceExcludingVAT: 335.2, Total: 419, TotalExcludingVAT: 335.2, VAT: 25 }],
|
||||
});
|
||||
|
||||
expect(dto.lines).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('adds an administration fee row the same way', () => {
|
||||
const dto = mapFortnoxToSalesInvoice({
|
||||
...base,
|
||||
VATIncluded: false,
|
||||
Total: 1311,
|
||||
Net: 1000,
|
||||
TotalVAT: 262.25,
|
||||
Freight: 0,
|
||||
FreightVAT: 0,
|
||||
AdministrationFee: 49,
|
||||
AdministrationFeeVAT: 12.25,
|
||||
InvoiceRows: [{ RowId: 1, Total: 1000, TotalExcludingVAT: 1000, VAT: 25 }],
|
||||
});
|
||||
|
||||
const fee = dto.lines.at(-1);
|
||||
expect(fee).toMatchObject({ id: 'administration-fee', description: 'Administrationsavgift', taxPercent: 25 });
|
||||
expect(fee?.lineExtensionAmount.value).toBe(49);
|
||||
expect(fee?.taxAmount?.value).toBe(12.25);
|
||||
});
|
||||
|
||||
it('reads the string quantity Fortnox serialises as a number', () => {
|
||||
const dto = mapFortnoxToSalesInvoice({
|
||||
...base,
|
||||
VATIncluded: false,
|
||||
Total: 1250,
|
||||
Net: 1000,
|
||||
TotalVAT: 250,
|
||||
Freight: 0,
|
||||
FreightVAT: 0,
|
||||
InvoiceRows: [{ RowId: 1, DeliveredQuantity: '14', Total: 1000, TotalExcludingVAT: 1000, VAT: 25 }],
|
||||
});
|
||||
|
||||
expect(dto.lines[0]?.quantity).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,39 @@ function buildParty(name: string, orgNumber?: string, address?: Record<string, u
|
||||
};
|
||||
}
|
||||
|
||||
/** Header-level charges Fortnox keeps outside InvoiceRows, as rows. */
|
||||
const FORTNOX_HEADER_CHARGES = [
|
||||
{ id: 'freight', amountKey: 'Freight', vatKey: 'FreightVAT', description: 'Frakt' },
|
||||
{ id: 'administration-fee', amountKey: 'AdministrationFee', vatKey: 'AdministrationFeeVAT', description: 'Administrationsavgift' },
|
||||
] as const;
|
||||
|
||||
function headerChargeLines(
|
||||
raw: Record<string, unknown>,
|
||||
vatIncluded: boolean,
|
||||
currency: string,
|
||||
): SalesInvoiceLineDto[] {
|
||||
const lines: SalesInvoiceLineDto[] = [];
|
||||
for (const charge of FORTNOX_HEADER_CHARGES) {
|
||||
const stated = readNumber(raw, [charge.amountKey]);
|
||||
if (!stated) continue;
|
||||
const vatAmount = readNumber(raw, [charge.vatKey]) ?? 0;
|
||||
const net = vatIncluded ? roundOre(stated - vatAmount) : stated;
|
||||
// The rate is not stated for a charge; it follows from the two amounts.
|
||||
const taxPercent = net !== 0 ? Math.round((vatAmount / net) * 100) : 0;
|
||||
lines.push({
|
||||
id: charge.id,
|
||||
description: charge.description,
|
||||
quantity: 1,
|
||||
unitPrice: amount(net, currency),
|
||||
lineExtensionAmount: amount(net, currency),
|
||||
taxPercent,
|
||||
taxAmount: amount(vatAmount, currency),
|
||||
itemName: charge.description,
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
|
||||
const currency = (raw['Currency'] as string) ?? 'SEK';
|
||||
const total = raw['Total'] as number ?? 0;
|
||||
@@ -161,7 +194,8 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
|
||||
return {
|
||||
id: String(row['RowId'] ?? idx + 1),
|
||||
description: row['Description'] as string | undefined,
|
||||
quantity: row['DeliveredQuantity'] as number | undefined,
|
||||
// Fortnox serialises the quantity as a string ("14"); read it as a number.
|
||||
quantity: readNumber(row, ['DeliveredQuantity']),
|
||||
unitCode: row['Unit'] as string | undefined,
|
||||
unitPrice: unitPrice !== undefined ? amount(unitPrice, currency) : undefined,
|
||||
lineExtensionAmount: amount(lineNet, currency),
|
||||
@@ -176,6 +210,16 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
|
||||
};
|
||||
});
|
||||
|
||||
// Freight and administration fee live on the header, not in InvoiceRows,
|
||||
// and Fortnox's `Net` excludes them while `TotalVAT` and `Total` include
|
||||
// them. Verified on live payloads (Profilio 295 and 242, 2026-09-05):
|
||||
// `Freight` is the fee as the customer saw it (gross when VATIncluded,
|
||||
// net otherwise) and `FreightVAT` is the VAT AMOUNT on it, not a rate
|
||||
// (88 and 22 on a 25 % invoice). The same pair exists for the fee. Without
|
||||
// these as rows, the rows sum to less than the header by exactly the
|
||||
// charge and the migration's rows-versus-header check refuses the invoice.
|
||||
lines.push(...headerChargeLines(raw, vatIncluded, currency));
|
||||
|
||||
const vat = resolveVatTriple({
|
||||
gross: total,
|
||||
net: readNumber(raw, FORTNOX_NET_KEYS),
|
||||
|
||||
Reference in New Issue
Block a user