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:
Jakob Wennberg
2026-09-05 11:41:17 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent e80ea74e76
commit 34bf5a7387
5 changed files with 254 additions and 3 deletions
@@ -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);
});
});
+45 -1
View File
@@ -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),