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
@@ -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