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
@@ -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',
}
}