fix(providers): map Fortnox VAT-inclusive rows net of VAT, and refuse migrated rows that contradict their header (#2302)

The first production run of the row-completion pass (#2291) wrote 345
Profilio invoices whose rows summed to the invoice GROSS with 25 % VAT
computed on top, beside a header (Net / TotalVAT) that was right. Fortnox
prices an invoice either excluding or including VAT and says which with
the invoice-level VATIncluded flag; the mapper had always read the row
Total and Price as net. Every such row set is 1.25 x its header net, to
the öre, across all 345.

- lib/providers/fortnox/mapper.ts: netOfVat() divides row Total and Price
  by (1 + rate) when VATIncluded is true; TotalExcludingVAT and
  PriceExcludingVAT are preferred when the payload carries them. A row
  without a rate cannot be split and keeps its amount.
- complete-invoice-lines.ts: rows whose net or VAT disagree with the header
  the same payload established by more than 1 kr are reported as
  rowsMismatch and left untouched. Öresavrundning stays inside the
  tolerance; VAT-inside rows, header-level freight and discounts do not.
  Rows that contradict their own header are worse than no rows.
- Cron summary carries rowsMismatch.

None of the 345 is open or booked; a separate repair removes today's rows
for them so the fixed pass refills them.


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 10:53:48 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 7c36d471b5
commit e80ea74e76
7 changed files with 184 additions and 5 deletions
@@ -99,3 +99,73 @@ describe('mapFortnoxToSupplierInvoice: VAT', () => {
expect(detail.taxTotal?.taxAmount.value).toBe(250);
});
});
describe('mapFortnoxToSalesInvoice: VATIncluded rows', () => {
// Profilio (2026-09-05): a 956 kr invoice priced with VAT inside. Its rows
// were stored as if net, summing to 956 with 25 % on top, beside a header
// that read 764.80 + 191.20 correctly.
const inclusive = {
DocumentNumber: 241,
InvoiceDate: '2025-06-02',
Currency: 'SEK',
Total: 956,
Balance: 0,
FullyPaid: true,
Net: 764.8,
TotalVAT: 191.2,
VATIncluded: true,
};
it('converts VAT-inclusive row amounts to net, unit price included', () => {
const dto = mapFortnoxToSalesInvoice({
...inclusive,
InvoiceRows: [
{ RowId: 1, Description: 'Mugg', DeliveredQuantity: 3, Price: 200, Total: 600, VAT: 25 },
{ RowId: 2, Description: 'Frakt', DeliveredQuantity: 1, Price: 356, Total: 356, VAT: 25 },
],
});
expect(dto.lines.map((l) => l.lineExtensionAmount.value)).toEqual([480, 284.8]);
expect(dto.lines.map((l) => l.unitPrice?.value)).toEqual([160, 284.8]);
expect(dto.lines.map((l) => l.taxAmount?.value)).toEqual([120, 71.2]);
const rowsNet = dto.lines.reduce((s, l) => s + l.lineExtensionAmount.value, 0);
expect(rowsNet).toBeCloseTo(764.8, 2);
// The header is unaffected: it always came from Net / TotalVAT.
expect(dto.legalMonetaryTotal.lineExtensionAmount?.value).toBe(764.8);
});
it('prefers the net the row states itself when the payload carries it', () => {
const dto = mapFortnoxToSalesInvoice({
...inclusive,
InvoiceRows: [
{ RowId: 1, Price: 956, PriceExcludingVAT: 764.8, Total: 956, TotalExcludingVAT: 764.8, VAT: 25 },
],
});
expect(dto.lines[0]?.lineExtensionAmount.value).toBe(764.8);
expect(dto.lines[0]?.unitPrice?.value).toBe(764.8);
});
it('leaves rows alone when the invoice is priced excluding VAT', () => {
const dto = mapFortnoxToSalesInvoice({
...inclusive,
VATIncluded: false,
InvoiceRows: [{ RowId: 1, Price: 764.8, Total: 764.8, VAT: 25 }],
});
expect(dto.lines[0]?.lineExtensionAmount.value).toBe(764.8);
expect(dto.lines[0]?.unitPrice?.value).toBe(764.8);
});
it('cannot split a VAT-inclusive row without a rate and keeps the amount', () => {
// Text rows and rows without VAT carry no rate; nothing to divide by. The
// consumer's rows-versus-header check is what reports such an invoice.
const dto = mapFortnoxToSalesInvoice({
...inclusive,
InvoiceRows: [{ RowId: 1, Description: 'Referens', Total: 0 }, { RowId: 2, Total: 956 }],
});
expect(dto.lines.map((l) => l.lineExtensionAmount.value)).toEqual([0, 956]);
expect(dto.lines[1]?.taxAmount).toBeUndefined();
});
});
+36 -4
View File
@@ -10,6 +10,29 @@ import type {
} from '../dto';
import { readNumber, resolveVatTriple, lineVatFromPercent } from '../amounts';
import { sourceVoucherFromParts } from '../source-voucher';
import { roundOre } from '@/lib/money';
/**
* A row amount net of VAT.
*
* Fortnox prices an invoice either excluding or including VAT, and says which
* with the invoice-level `VATIncluded` flag: when it is true the row `Price`
* and `Total` are the amounts the customer saw, VAT inside, and the net is
* the amount divided by (1 + rate). Newer payloads also carry the net on the
* row (`TotalExcludingVAT`, `PriceExcludingVAT`), which callers prefer when
* present; this is the fallback for the ones that do not. Without a stated
* rate the amount cannot be split and is returned as it is, and the
* consumer's rows-versus-header check reports the disagreement.
*
* Found on Profilio (2026-09-05): 345 VAT-inclusive invoices whose rows were
* stored as if net, so the rows summed to the gross and carried 25 % VAT on
* top of it, beside a header that was right.
*/
function netOfVat(amount: number, vatIncluded: boolean, ratePercent: number | undefined): number {
if (!vatIncluded || ratePercent === undefined) return amount;
const rate = ratePercent > 1 ? ratePercent / 100 : ratePercent;
return roundOre(amount / (1 + rate));
}
/**
* Fortnox splits its invoice payloads in two. `GET /3/invoices` answers with
@@ -118,12 +141,21 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
const paid = isFullyPaid(raw);
const balance = paid ? 0 : ((raw['Balance'] as number | undefined) ?? total);
// Whether the row amounts include VAT. Absent on the list form, where there
// are no rows anyway; false is the default when the detail form omits it.
const vatIncluded = raw['VATIncluded'] === true;
const rows = (raw['InvoiceRows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => {
// Fortnox `Total` on a row is the line amount excluding VAT; `VAT` is the
// rate in percent (25), not an amount.
const lineNet = readNumber(row, ['Total']) ?? 0;
// `VAT` on a row is the rate in percent (25), not an amount. `Total` and
// `Price` are net only when the invoice is priced excluding VAT; see
// netOfVat for the VATIncluded case.
const taxPercent = readNumber(row, ['VAT']);
const lineNet = readNumber(row, ['TotalExcludingVAT'])
?? netOfVat(readNumber(row, ['Total']) ?? 0, vatIncluded, taxPercent);
const rawPrice = readNumber(row, ['Price']);
const unitPrice = readNumber(row, ['PriceExcludingVAT'])
?? (rawPrice !== undefined ? netOfVat(rawPrice, vatIncluded, taxPercent) : undefined);
const lineVat = lineVatFromPercent(lineNet, taxPercent);
return {
@@ -131,7 +163,7 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
description: row['Description'] as string | undefined,
quantity: row['DeliveredQuantity'] as number | undefined,
unitCode: row['Unit'] as string | undefined,
unitPrice: row['Price'] != null ? amount(row['Price'] as number, currency) : undefined,
unitPrice: unitPrice !== undefined ? amount(unitPrice, currency) : undefined,
lineExtensionAmount: amount(lineNet, currency),
taxPercent,
// Fortnox states the rate per row but not the money. Deriving it here is