* 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>
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { mapFortnoxToSalesInvoice, mapFortnoxToSupplierInvoice } from '../mapper';
|
|
|
|
/**
|
|
* Fortnox answers `GET /3/invoices` with the short form and
|
|
* `GET /3/invoices/{n}` with the full one. Only the full form carries `Net`,
|
|
* `TotalVAT` and `InvoiceRows`, and the migration used to map the short form
|
|
* alone: 8 686 invoices landed claiming 25 % moms with 0 kr of it.
|
|
*/
|
|
describe('mapFortnoxToSalesInvoice: VAT', () => {
|
|
const listForm = {
|
|
DocumentNumber: 4,
|
|
InvoiceDate: '2025-11-01',
|
|
DueDate: '2025-12-01',
|
|
CustomerName: 'Ronaldiniho',
|
|
Currency: 'SEK',
|
|
Total: 1845000,
|
|
Balance: 1845000,
|
|
Sent: true,
|
|
};
|
|
|
|
it('reports the net and VAT as UNKNOWN for the list form', () => {
|
|
const dto = mapFortnoxToSalesInvoice(listForm);
|
|
|
|
// Not "net equals gross, VAT is 0": the short form simply did not say.
|
|
expect(dto.legalMonetaryTotal.lineExtensionAmount).toBeUndefined();
|
|
expect(dto.taxTotal).toBeUndefined();
|
|
// The one figure the payload does establish survives.
|
|
expect(dto.legalMonetaryTotal.payableAmount.value).toBe(1845000);
|
|
expect(dto.lines).toEqual([]);
|
|
});
|
|
|
|
it('reads Net and TotalVAT from the detail form', () => {
|
|
const dto = mapFortnoxToSalesInvoice({
|
|
...listForm,
|
|
Net: 1476000,
|
|
TotalVAT: 369000,
|
|
InvoiceRows: [
|
|
{ RowId: 1, Description: 'Konsultarvode', DeliveredQuantity: 1, Price: 1476000, Total: 1476000, VAT: 25 },
|
|
],
|
|
});
|
|
|
|
expect(dto.legalMonetaryTotal.lineExtensionAmount?.value).toBe(1476000);
|
|
expect(dto.taxTotal?.taxAmount.value).toBe(369000);
|
|
});
|
|
|
|
it('derives per-line VAT from the row rate, which Fortnox states without the amount', () => {
|
|
const dto = mapFortnoxToSalesInvoice({
|
|
...listForm,
|
|
Net: 1476000,
|
|
TotalVAT: 369000,
|
|
InvoiceRows: [
|
|
{ RowId: 1, Total: 1000000, VAT: 25 },
|
|
{ RowId: 2, Total: 476000, VAT: 25 },
|
|
],
|
|
});
|
|
|
|
// The booking engine sums these to post 2611; 0 here posts no output VAT.
|
|
expect(dto.lines[0]?.taxAmount?.value).toBe(250000);
|
|
expect(dto.lines[1]?.taxAmount?.value).toBe(119000);
|
|
expect(dto.lines[0]?.taxPercent).toBe(25);
|
|
});
|
|
|
|
it('leaves line VAT unknown when the row states no rate', () => {
|
|
const dto = mapFortnoxToSalesInvoice({
|
|
...listForm,
|
|
InvoiceRows: [{ RowId: 1, Total: 1000 }],
|
|
});
|
|
|
|
expect(dto.lines[0]?.taxAmount).toBeUndefined();
|
|
});
|
|
|
|
it('keeps a genuine 0 % row at 0, distinct from unknown', () => {
|
|
const dto = mapFortnoxToSalesInvoice({
|
|
...listForm,
|
|
InvoiceRows: [{ RowId: 1, Total: 1000, VAT: 0 }],
|
|
});
|
|
|
|
expect(dto.lines[0]?.taxAmount?.value).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('mapFortnoxToSupplierInvoice: VAT', () => {
|
|
it('reports the net as unknown without Net, and reads it when present', () => {
|
|
const base = {
|
|
GivenNumber: 77,
|
|
InvoiceDate: '2025-11-01',
|
|
SupplierName: 'Leverantör AB',
|
|
Currency: 'SEK',
|
|
Total: 1250,
|
|
Balance: 1250,
|
|
};
|
|
|
|
expect(mapFortnoxToSupplierInvoice(base).legalMonetaryTotal.lineExtensionAmount)
|
|
.toBeUndefined();
|
|
|
|
const detail = mapFortnoxToSupplierInvoice({ ...base, Net: 1000, TotalVAT: 250 });
|
|
expect(detail.legalMonetaryTotal.lineExtensionAmount?.value).toBe(1000);
|
|
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();
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|