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:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
7c36d471b5
commit
e80ea74e76
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user