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
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user