fix(providers): stop inventing 25% VAT on migrated invoices (#1745)

* fix(providers): stop inventing 25% VAT on migrated invoices

An invoice migrated from Fortnox displayed "Momsbehandling: 25 % moms"
next to "Moms: 0 kr", with no line items behind it. It was not a display
bug: the record really did hold vat_rate 25 and vat_amount 0.

Fortnox answers GET /3/invoices with the short form, which carries no
Net, no TotalVAT and no InvoiceRows; those live only on the detail form.
The migration mapped the list payload alone, so `Net ?? total` made the
net equal the gross, VAT derived as gross minus net came out 0, and with
no rows to read a rate from, inferVatTreatment/inferVatRate fell through
to their `return 'standard_25'` / `return 25` defaults. The result
balanced, so nothing downstream noticed.

Measured on prod: 8 712 sales invoices across 43 companies assert a rate
beside 0 kr of VAT (286 MSEK of subtotal), plus 1 240 supplier invoices.
None are booked, but 263 are still open, and the no-items booking
fallback in invoice-entries.ts credits the full gross to 30xx and emits
no 2611 line at all.

Not Fortnox-only. Visma reported its VAT-inclusive TotalAmount as the
ex-VAT amount and read rows via `LineTotal`/`VatRatePercent`, neither of
which exists in the eAccounting schema (the real names are AmountNoVat
and PercentVat), so its lines all landed at 0. Bjorn Lunden reported the
gross as the net with no lines at all. Briox and WINT had the same
gross-as-net fallback, and Bokio defaulted a missing totalTax to 0.

- lib/providers/amounts.ts: readers that return undefined for an absent
  field, so "the provider says zero" stays distinct from "did not say"
- every mapper: populate taxTotal and per-line taxAmount from what the
  payload actually states; leave the net undefined when it does not
- provider-data-fetcher: hydrate the detail endpoint that every config
  has always declared and nothing ever called, open invoices first,
  within a time budget, reporting whatever it could not reach
- entity-mapper: derive rate and treatment from evidence; when there is
  none, write vat_rate null and flag vatUnresolved instead of asserting
  a standard rate

Existing rows are untouched; repairing them needs a separate decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(providers): keep subtotal + VAT equal to the invoice total

Providers state net, VAT and gross independently and they need not agree:
Fortnox's Total is the amount to pay after öresavrundning while
Net + TotalVAT is the unrounded Gross, so the two differ by up to 50 öre.

Passing both through as stated put that gap into the invoice row, where
subtotal + vat_amount no longer equalled total. The header booking path
in invoice-entries.ts derives the 1510 debit from the sum of its credits,
so the receivable would land a few öre away from what the customer owes
while the verifikat still balanced: the same silent shape as the bug this
branch fixes.

resolveVatTriple now always returns a pair summing to the gross, keeping
the VAT intact (it reaches the momsdeklaration) and absorbing the
rounding into the net.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(providers): address invoice detail by the configured idField

Hydration built the detail path from dto.id. Björn Lundén's sales config
names invoiceNumber as its idField while its mapper builds dto.id from
entityId, so BL sales invoices would have been hydrated from the wrong
resource, or from none. Every other provider/resource pair happens to
agree on the two, which is what made the mismatch easy to miss.

The config's idField is the authority, read off the raw payload, with
dto.id only as the fallback. The regression test uses BL with entityId
99001 and invoiceNumber 5 so the two cannot coincide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(providers): store vat_rate null for migrated mixed-rate invoices

resolveInvoiceVat labelled the header with the first line's rate, so an
invoice carrying both 25 % and 6 % lines was recorded as a 25 % invoice.
buildInvoiceWriteData already stores isMixedRate ? null : theRate for
natively created invoices; migrated ones now match.

The money was already right and stays right: generatePerRateLines groups
per item rate, so a mixed invoice books 25 % and 6 % separately off the
per-line vat_rate/vat_amount this branch fixed. Only the header label was
overstating what the source said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(providers): bound hydration against auth failures and the clock

Two failure modes that only appear against a real provider.

A 401 or 403 fails identically for every remaining invoice, so the pass
now stops on the first one instead of issuing hundreds more doomed
calls. That matters more than it looks: TokenBucketRateLimiter keys on
the literal string 'global', so Fortnox's 4 req/s is a platform-wide
budget shared by every company and every concurrent migration, not a
per-token one. A 404 is about one invoice and does not stop the pass.

The budget was checked before starting a call but never during one. The
clients retry 429s and 5xx with backoff (Fortnox: 6 attempts, up to 60 s
apart), so a call starting one millisecond inside the budget could still
be retrying minutes later, and three concurrent ones could hold the
migration past its 300 s function ceiling. Each call is now raced
against the deadline; the socket is not cancelled, but control returns
and the remaining invoices are reported unhydrated instead of the run
dying.

Both outcomes are reported as HydrationReport.abortedBy so a partial
pass is visible rather than looking complete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <invoice@arcim.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-22 12:07:52 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg
parent 51c815254a
commit dc5079a912
20 changed files with 1907 additions and 116 deletions
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import {
readNumber,
resolveVatTriple,
sumLineVat,
lineVatFromPercent,
multiplyIfBothPresent,
} from '../amounts';
describe('readNumber', () => {
it('returns the first present candidate, in order', () => {
expect(readNumber({ b: 2, a: 1 }, ['a', 'b'])).toBe(1);
expect(readNumber({ b: 2 }, ['a', 'b'])).toBe(2);
});
it('distinguishes an absent field from a zero one', () => {
expect(readNumber({ Net: 0 }, ['Net'])).toBe(0);
expect(readNumber({}, ['Net'])).toBeUndefined();
});
it('coerces string-serialised decimals', () => {
expect(readNumber({ net_amount: '1476.00' }, ['net_amount'])).toBe(1476);
});
it('skips nulls, empty strings and non-numerics rather than reading them as 0', () => {
expect(readNumber({ a: null, b: '', c: 'n/a', d: 5 }, ['a', 'b', 'c', 'd'])).toBe(5);
expect(readNumber({ a: null }, ['a'])).toBeUndefined();
});
it('tolerates a missing payload', () => {
expect(readNumber(undefined, ['a'])).toBeUndefined();
});
});
describe('resolveVatTriple', () => {
it('completes VAT from a stated net', () => {
expect(resolveVatTriple({ gross: 1845000, net: 1476000 }))
.toEqual({ net: 1476000, vat: 369000 });
});
it('completes the net from a stated VAT', () => {
expect(resolveVatTriple({ gross: 1845000, vat: 369000 }))
.toEqual({ net: 1476000, vat: 369000 });
});
it('passes both through when both are stated and they agree', () => {
expect(resolveVatTriple({ gross: 100, net: 80, vat: 20 }))
.toEqual({ net: 80, vat: 20 });
});
it('keeps net + vat === gross when the provider states three that disagree', () => {
// Fortnox `Total` is post-öresavrundning while Net + TotalVAT is the
// unrounded Gross. Passing both through as stated would leave
// subtotal + vat_amount != total on the invoice row, and the header
// booking path derives the 1510 debit from the sum of its credits: the
// receivable would sit a few öre off what the customer owes, with the
// verifikat still balancing so nothing flags it.
const resolved = resolveVatTriple({ gross: 1250, net: 1000.4, vat: 250 });
expect(resolved.vat).toBe(250);
expect(resolved.net).toBe(1000);
expect((resolved.net ?? 0) + (resolved.vat ?? 0)).toBe(1250);
});
it('resolves NOTHING from a gross alone', () => {
// The regression this whole module exists for: with only the payable
// amount known, net = gross and VAT = 0 is an invention, not a default.
expect(resolveVatTriple({ gross: 1845000 })).toEqual({});
});
it('treats a genuine zero VAT as an answer, not as absence', () => {
expect(resolveVatTriple({ gross: 1000, vat: 0 })).toEqual({ net: 1000, vat: 0 });
});
});
describe('sumLineVat', () => {
it('sums when every line carries an amount', () => {
expect(sumLineVat([
{ taxAmount: { value: 250 } },
{ taxAmount: { value: 60 } },
])).toBe(310);
});
it('refuses a partial sum when any line is missing its VAT', () => {
expect(sumLineVat([
{ taxAmount: { value: 250 } },
{},
])).toBeUndefined();
});
it('returns undefined for no lines', () => {
expect(sumLineVat([])).toBeUndefined();
});
});
describe('lineVatFromPercent', () => {
it('accepts both unit conventions', () => {
expect(lineVatFromPercent(1000, 25)).toBe(250);
expect(lineVatFromPercent(1000, 0.25)).toBe(250);
});
it('computes 0 for a stated 0 % rate', () => {
expect(lineVatFromPercent(1000, 0)).toBe(0);
});
it('returns undefined for an unstated rate rather than assuming one', () => {
expect(lineVatFromPercent(1000, undefined)).toBeUndefined();
});
it('rounds to öre without toFixed drift', () => {
expect(lineVatFromPercent(333.33, 25)).toBe(83.33);
});
});
describe('multiplyIfBothPresent', () => {
it('multiplies when both factors are known', () => {
expect(multiplyIfBothPresent(100, 3)).toBe(300);
});
it('returns undefined when either factor is missing', () => {
expect(multiplyIfBothPresent(100, undefined)).toBeUndefined();
expect(multiplyIfBothPresent(undefined, 3)).toBeUndefined();
});
});
@@ -0,0 +1,204 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchSalesInvoicesHydrated } from '../provider-data-fetcher';
/**
* Hydration is what makes the VAT fix work in production: the list payload
* Fortnox returns has no `Net`, no `TotalVAT` and no `InvoiceRows`, so without
* a second call there is nothing to map. These tests pin the three properties
* that keep it safe at the observed volumes (up to 1 911 invoices per company
* against a 4 req/s limit and a 300 s function ceiling): only fetch what is
* missing, serve open invoices first, and report whatever the budget missed.
*/
function listResponse(invoices: Record<string, unknown>[]) {
return {
Invoices: invoices,
MetaInformation: { '@TotalPages': 1, '@CurrentPage': 1, '@TotalResources': invoices.length },
};
}
const OPEN = { DocumentNumber: 4, InvoiceDate: '2025-11-01', Currency: 'SEK', Total: 1250, Balance: 1250 };
const PAID = { DocumentNumber: 5, InvoiceDate: '2025-11-02', Currency: 'SEK', Total: 500, Balance: 0, FullyPaid: true };
function detailFor(documentNumber: number, total: number) {
return {
Invoice: {
DocumentNumber: documentNumber,
InvoiceDate: '2025-11-01',
Currency: 'SEK',
Total: total,
Balance: total,
Net: total * 0.8,
TotalVAT: total * 0.2,
InvoiceRows: [{ RowId: 1, Total: total * 0.8, VAT: 25 }],
},
};
}
describe('fetchSalesInvoicesHydrated (fortnox)', () => {
let requested: string[];
beforeEach(() => {
requested = [];
vi.stubEnv('UPSTASH_REDIS_REST_URL', '');
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', '');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
function stubFetch(handler: (url: string) => Response) {
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
requested.push(url);
return handler(url);
}));
}
const json = (body: unknown) =>
new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
it('fills in the VAT the list payload omitted', async () => {
stubFetch((url) => url.includes('/invoices/4') ? json(detailFor(4, 1250)) : json(listResponse([OPEN])));
const { invoices, hydration } = await fetchSalesInvoicesHydrated('fortnox', 'token');
expect(hydration).toMatchObject({ needed: 1, hydrated: 1, failed: 0, skippedForBudget: 0 });
expect(invoices[0]?.taxTotal?.taxAmount.value).toBe(250);
expect(invoices[0]?.legalMonetaryTotal.lineExtensionAmount?.value).toBe(1000);
expect(invoices[0]?.lines).toHaveLength(1);
});
it('does not spend a request on an invoice that is already complete', async () => {
// A list payload that already carries Net, TotalVAT and rows has nothing
// to gain from its detail form.
const complete = { ...OPEN, Net: 1000, TotalVAT: 250, InvoiceRows: [{ RowId: 1, Total: 1000, VAT: 25 }] };
stubFetch(() => json(listResponse([complete])));
const { hydration } = await fetchSalesInvoicesHydrated('fortnox', 'token');
expect(hydration).toMatchObject({ needed: 0, hydrated: 0 });
expect(requested.filter((u) => u.includes('/invoices/4'))).toHaveLength(0);
});
it('requests the OPEN invoice before the paid one', async () => {
// Open invoices are the ones a later payment match or credit note books,
// so they must not be the ones a tight budget drops.
stubFetch((url) => {
if (url.includes('/invoices/5')) return json(detailFor(5, 500));
if (url.includes('/invoices/4')) return json(detailFor(4, 1250));
return json(listResponse([PAID, OPEN]));
});
await fetchSalesInvoicesHydrated('fortnox', 'token');
const details = requested.filter((u) => /\/invoices\/\d/.test(u));
expect(details[0]).toContain('/invoices/4');
});
it('reports what the budget could not reach instead of looking complete', async () => {
stubFetch(() => json(listResponse([OPEN, PAID])));
const { invoices, hydration } = await fetchSalesInvoicesHydrated('fortnox', 'token', undefined, 0);
expect(hydration).toMatchObject({ needed: 2, hydrated: 0, skippedForBudget: 2 });
// The invoices themselves are still returned, unhydrated.
expect(invoices).toHaveLength(2);
expect(requested.filter((u) => /\/invoices\/\d/.test(u))).toHaveLength(0);
});
it('stops the whole pass when the provider rejects the token', async () => {
// 401/403 fails identically for every remaining invoice. Issuing hundreds
// more doomed calls would spend the Fortnox rate-limit budget (shared
// platform-wide) for nothing.
// More invoices than HYDRATION_CONCURRENCY, so the abort has something
// left to prevent: the first few are already in flight when the first 401
// lands, and everything after that must never be requested.
const many = Array.from({ length: 20 }, (_, i) => ({ ...OPEN, DocumentNumber: 100 + i }));
stubFetch((url) => url.includes('/invoices/1')
? new Response('unauthorized', { status: 401 })
: json(listResponse(many)));
const { invoices, hydration } = await fetchSalesInvoicesHydrated('fortnox', 'token');
expect(hydration.abortedBy).toBe('auth');
expect(hydration.hydrated).toBe(0);
// Every invoice is still returned, unhydrated.
expect(invoices).toHaveLength(20);
// Only the concurrency window was spent, not all 20.
const details = requested.filter((u) => /\/invoices\/\d\d\d/.test(u));
expect(details.length).toBeGreaterThan(0);
expect(details.length).toBeLessThanOrEqual(3);
});
it('does not stop the pass on a 404 for one invoice', async () => {
// A missing invoice is about that invoice, not the credential.
stubFetch((url) => {
if (url.includes('/invoices/4')) return new Response('gone', { status: 404 });
if (url.includes('/invoices/5')) return json(detailFor(5, 500));
return json(listResponse([OPEN, PAID]));
});
const { hydration } = await fetchSalesInvoicesHydrated('fortnox', 'token');
expect(hydration.abortedBy).toBeUndefined();
expect(hydration.failed).toBe(1);
expect(hydration.hydrated).toBe(1);
});
it('keeps the list-form invoice when its detail fetch fails', async () => {
stubFetch((url) => url.includes('/invoices/4')
? new Response('boom', { status: 404 })
: json(listResponse([OPEN])));
const { invoices, hydration } = await fetchSalesInvoicesHydrated('fortnox', 'token');
expect(hydration).toMatchObject({ needed: 1, hydrated: 0, failed: 1 });
expect(invoices).toHaveLength(1);
expect(invoices[0]?.legalMonetaryTotal.payableAmount.value).toBe(1250);
});
});
describe('fetchSalesInvoicesHydrated: detail id comes from the configured idField', () => {
beforeEach(() => {
vi.stubEnv('UPSTASH_REDIS_REST_URL', '');
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', '');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it('uses BL invoiceNumber, not the entityId its DTO id is built from', async () => {
// Björn Lundén's sales config names `invoiceNumber` as idField, while its
// mapper sets dto.id from `entityId`. Addressing the detail endpoint by
// dto.id would request invoice 99001 instead of 5. The two differ here on
// purpose: with equal values the test would pass either way.
const seen: string[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
seen.push(url);
if (url.includes('/customerinvoice/batch')) {
return new Response(JSON.stringify({
data: [{ entityId: 99001, invoiceNumber: 5, invoiceDate: '2025-11-01', currency: 'SEK', amountInLocalCurrency: 1250, paid: false }],
totalPages: 1,
pageRequested: 1,
totalRows: 1,
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({
entityId: 99001, invoiceNumber: 5, invoiceDate: '2025-11-01',
currency: 'SEK', amountInLocalCurrency: 1250, vatAmount: 250, paid: false,
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}));
const { invoices } = await fetchSalesInvoicesHydrated('bjornlunden', 'token', 'user-key');
const detail = seen.find((u) => u.includes('/customerinvoice/') && !u.includes('/batch'));
expect(detail).toContain('/customerinvoice/5');
expect(detail).not.toContain('99001');
// And the hydrated payload's VAT actually landed.
expect(invoices[0]?.taxTotal?.taxAmount.value).toBe(250);
});
});
+139
View File
@@ -0,0 +1,139 @@
import { roundOre } from '@/lib/money';
/**
* Numeric field readers for provider payloads.
*
* Every provider mapper used to collapse "this field is not in the payload"
* into a number with `?? 0` or `?? total`. For VAT that is not a harmless
* default: it is an assertion. A missing `TotalVAT` became "0 kr moms", a
* missing `Net` became "net equals gross", and the migration then wrote an
* invoice claiming 25 % moms alongside 0 kr of it. The record balanced, so
* nothing downstream complained.
*
* These helpers return `undefined` for an absent field so callers can tell
* "the provider says zero" from "the provider did not say". Deciding what to
* do with genuinely unknown VAT belongs to the caller, not to a `??`.
*/
/**
* First present, finite numeric value among `keys`.
*
* Providers spell the same quantity differently across endpoints and API
* versions (Fortnox `Net` vs `NetAmount`, Visma `TotalAmount` vs
* `TotalAmountInvoiceCurrency`), and the live payloads have repeatedly
* differed from the published spec. Taking a candidate list rather than a
* single key means an unexpected spelling degrades to `undefined`, which is
* flagged, instead of to a fabricated zero, which is not.
*
* Strings are accepted because several providers serialise decimals as
* strings; empty strings and nulls are not numbers and are skipped.
*/
export function readNumber(
raw: Record<string, unknown> | undefined | null,
keys: readonly string[],
): number | undefined {
if (!raw) return undefined;
for (const key of keys) {
if (!(key in raw)) continue;
const value = raw[key];
if (value === null || value === undefined || value === '') continue;
const n = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(n)) return n;
}
return undefined;
}
/**
* The VAT figures a provider payload yielded, with "unknown" preserved.
*
* `net` and `vat` are independently optional: Fortnox's detail payload gives
* both, Björn Lundén's list payload gives neither, and a provider that gives
* only one still lets the third be derived against `gross`.
*/
export interface ResolvedVat {
/** Amount excluding VAT, or undefined when no evidence established it. */
net?: number;
/** VAT amount, or undefined when no evidence established it. */
vat?: number;
}
/**
* Complete a net/VAT/gross triple from whichever two are known.
*
* Returns only what the inputs support. With just a gross total, both fields
* come back undefined rather than net = gross and VAT = 0: "we only know what
* the customer paid" is the honest reading, and the caller flags it.
*
* The returned pair ALWAYS satisfies `net + vat === gross`. Providers state
* all three independently and they need not agree: Fortnox's `Total` is the
* amount to pay after öresavrundning, while `Net + TotalVAT` is the unrounded
* `Gross`, so the two differ by up to 50 öre. Passing both through as stated
* would put that gap into the invoice row, where `subtotal + vat_amount` no
* longer equals `total`; the header booking path derives the 1510 debit from
* the sum of its credits, so the receivable would land a few öre away from
* what the customer actually owes while the verifikat still balanced. The VAT
* is the figure that must survive intact (it reaches the momsdeklaration), so
* the gap is absorbed into the net.
*/
export function resolveVatTriple(params: {
gross: number;
net?: number;
vat?: number;
}): ResolvedVat {
const { gross, net, vat } = params;
if (vat !== undefined) return { net: roundOre(gross - vat), vat };
if (net !== undefined) return { net, vat: roundOre(gross - net) };
return {};
}
/**
* Product of two optional numbers, undefined unless both are present.
*
* Used to reconstruct a line amount from unit price x quantity when the
* provider's own line-total field is absent. Returning undefined for a
* missing factor keeps a half-known line from being recorded as 0.
*/
export function multiplyIfBothPresent(
a: number | undefined,
b: number | undefined,
): number | undefined {
if (a === undefined || b === undefined) return undefined;
return roundOre(a * b);
}
/**
* Sum per-line VAT when every line carries an amount, else undefined.
*
* A partial sum would understate the total, so one line missing its VAT
* discards the whole sum rather than reporting a number that is too low.
*/
export function sumLineVat(
lines: readonly { taxAmount?: { value: number } }[],
): number | undefined {
if (lines.length === 0) return undefined;
if (lines.some((line) => line.taxAmount === undefined)) return undefined;
return roundOre(lines.reduce((sum, line) => sum + (line.taxAmount?.value ?? 0), 0));
}
/**
* VAT for one line from its rate and net amount.
*
* Only when the provider actually stated a rate: `taxPercent` undefined means
* the rate is unknown, and 0 % is a real answer that must not be invented.
* Accepts both unit conventions (25 and 0.25) because providers mix them.
*/
export function lineVatFromPercent(
lineNet: number,
taxPercent: number | undefined,
): number | undefined {
if (taxPercent === undefined || !Number.isFinite(taxPercent)) return undefined;
const rate = taxPercent > 1 ? taxPercent / 100 : taxPercent;
return roundOre(lineNet * rate);
}
+38 -2
View File
@@ -9,6 +9,26 @@ import type {
CompanyInformationDto,
AmountType, PartyDto,
} from '../dto';
import { readNumber, resolveVatTriple } from '../amounts';
/**
* BL's invoice list carries only gross amounts (`amountInLocalCurrency`) and
* no line items. Those grosses used to be reported as `lineExtensionAmount`,
* i.e. as the amount EXCLUDING VAT, so the migration derived 0 kr of VAT for
* every BL invoice while still labelling it 25 % moms.
*
* BL's list payload is not documented to carry a VAT total; the candidates
* below cover the spellings its endpoints use elsewhere. When none matches,
* the VAT stays unknown and the migration reports it, instead of the mapper
* asserting zero. Hydrating the detail endpoint (provider-data-fetcher) is
* what actually supplies line items for BL.
*/
const BL_VAT_KEYS = [
'vatAmountInLocalCurrency',
'vatAmount',
'vat',
'totalVat',
] as const;
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
return { value: value ?? 0, currencyCode: currency };
@@ -62,8 +82,15 @@ export function mapBLToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceD
identifications: raw['customerId'] ? [{ id: String(raw['customerId']), schemeId: 'BL:CUSTOMER_ID' }] : [],
};
const vat = resolveVatTriple({
gross: totalAmount,
vat: readNumber(raw, BL_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(totalAmount, currency),
// `amountInLocalCurrency` is the gross. Reporting it as the net is what
// made every BL invoice look like a 0 kr VAT sale.
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(totalAmount, currency),
payableAmount: amount(totalAmount, currency),
};
@@ -83,6 +110,7 @@ export function mapBLToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceD
supplier: { name: '', identifications: [] },
customer,
lines: [], // BL doesn't include line items in list responses
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
_raw: raw,
@@ -107,8 +135,15 @@ export function mapBLToSupplierInvoice(raw: Record<string, unknown>): SupplierIn
identifications: raw['supplierId'] ? [{ id: String(raw['supplierId']), schemeId: 'BL:SUPPLIER_ID' }] : [],
};
const vat = resolveVatTriple({
gross: totalAmount,
vat: readNumber(raw, BL_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(totalAmount, currency),
// `amountInLocalCurrency` is the gross. Reporting it as the net is what
// made every BL invoice look like a 0 kr VAT sale.
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(totalAmount, currency),
payableAmount: amount(totalAmount, currency),
};
@@ -128,6 +163,7 @@ export function mapBLToSupplierInvoice(raw: Record<string, unknown>): SupplierIn
supplier,
buyer: { name: '', identifications: [] },
lines: [], // BL doesn't include line items in list responses
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
_raw: raw,
+44 -12
View File
@@ -9,6 +9,22 @@ import type {
CompanyInformationDto,
AmountType, PartyDto,
} from '../dto';
import {
readNumber,
resolveVatTriple,
lineVatFromPercent,
multiplyIfBothPresent,
} from '../amounts';
/**
* Bokio's live payloads have repeatedly differed from its published spec (the
* company-information body was the previous case), and `totalTax` is absent
* from the supplier-invoice payload in practice: defaulting it to 0 recorded
* every such invoice with its gross as its net and no VAT. Candidates cover
* the spellings seen across Bokio's endpoints; none matching leaves the VAT
* unknown, which the migration reports, rather than zero, which it cannot see.
*/
const BOKIO_VAT_KEYS = ['totalTax', 'totalVat', 'vatAmount', 'taxAmount'] as const;
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
return { value: value ?? 0, currencyCode: currency };
@@ -54,7 +70,6 @@ function buildParty(name: string, orgNumber?: string, address?: Record<string, u
export function mapBokioToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
const currency = (raw['currency'] as string) ?? 'SEK';
const totalAmount = (raw['totalAmount'] as number) ?? 0;
const totalTax = (raw['totalTax'] as number) ?? 0;
const paidAmount = (raw['paidAmount'] as number) ?? 0;
const balance = totalAmount - paidAmount;
@@ -62,9 +77,11 @@ export function mapBokioToSalesInvoice(raw: Record<string, unknown>): SalesInvoi
const rawLines = (raw['lineItems'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SalesInvoiceLineDto[] = rawLines.map((line, idx) => {
const unitPrice = line['unitPrice'] as number | undefined;
const quantity = line['quantity'] as number | undefined;
const lineTotal = unitPrice != null && quantity != null ? unitPrice * quantity : 0;
const unitPrice = readNumber(line, ['unitPrice']);
const quantity = readNumber(line, ['quantity']);
const lineTotal = multiplyIfBothPresent(unitPrice, quantity);
const taxPercent = readNumber(line, ['taxRate']);
const lineVat = lineTotal !== undefined ? lineVatFromPercent(lineTotal, taxPercent) : undefined;
return {
id: String(line['id'] ?? idx + 1),
@@ -73,12 +90,18 @@ export function mapBokioToSalesInvoice(raw: Record<string, unknown>): SalesInvoi
unitCode: line['unitType'] as string | undefined,
unitPrice: unitPrice != null ? amount(unitPrice, currency) : undefined,
lineExtensionAmount: amount(lineTotal, currency),
taxPercent: line['taxRate'] as number | undefined,
taxPercent,
taxAmount: lineVat !== undefined ? amount(lineVat, currency) : undefined,
};
});
const vat = resolveVatTriple({
gross: totalAmount,
vat: readNumber(raw, BOKIO_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(totalAmount - totalTax, currency),
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(totalAmount, currency),
payableAmount: amount(totalAmount, currency),
};
@@ -100,6 +123,7 @@ export function mapBokioToSalesInvoice(raw: Record<string, unknown>): SalesInvoi
(customerRef?.['name'] as string) ?? '',
),
lines,
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
_raw: raw,
@@ -252,7 +276,6 @@ export function mapBokioToSupplier(raw: Record<string, unknown>): SupplierDto {
export function mapBokioToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
const currency = (raw['currency'] as string) ?? 'SEK';
const totalAmount = (raw['totalAmount'] as number) ?? 0;
const totalTax = (raw['totalTax'] as number) ?? 0;
const paidAmount = (raw['paidAmount'] as number) ?? 0;
const balance = totalAmount - paidAmount;
@@ -260,9 +283,11 @@ export function mapBokioToSupplierInvoice(raw: Record<string, unknown>): Supplie
const rawLines = (raw['lineItems'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SupplierInvoiceLineDto[] = rawLines.map((line, idx) => {
const unitPrice = line['unitPrice'] as number | undefined;
const quantity = line['quantity'] as number | undefined;
const lineTotal = unitPrice != null && quantity != null ? unitPrice * quantity : 0;
const unitPrice = readNumber(line, ['unitPrice']);
const quantity = readNumber(line, ['quantity']);
const lineTotal = multiplyIfBothPresent(unitPrice, quantity);
const taxPercent = readNumber(line, ['taxRate']);
const lineVat = lineTotal !== undefined ? lineVatFromPercent(lineTotal, taxPercent) : undefined;
return {
id: String(line['id'] ?? idx + 1),
@@ -271,12 +296,18 @@ export function mapBokioToSupplierInvoice(raw: Record<string, unknown>): Supplie
unitCode: line['unitType'] as string | undefined,
unitPrice: unitPrice != null ? amount(unitPrice, currency) : undefined,
lineExtensionAmount: amount(lineTotal, currency),
taxPercent: line['taxRate'] as number | undefined,
taxPercent,
taxAmount: lineVat !== undefined ? amount(lineVat, currency) : undefined,
};
});
const vat = resolveVatTriple({
gross: totalAmount,
vat: readNumber(raw, BOKIO_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(totalAmount - totalTax, currency),
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(totalAmount, currency),
payableAmount: amount(totalAmount, currency),
};
@@ -298,6 +329,7 @@ export function mapBokioToSupplierInvoice(raw: Record<string, unknown>): Supplie
),
buyer: buildParty(''),
lines,
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
ocrNumber: raw['ocrNumber'] as string | undefined,
+52 -14
View File
@@ -8,6 +8,7 @@ import type {
CompanyInformationDto,
AmountType, PartyDto,
} from '../dto';
import { readNumber, resolveVatTriple, lineVatFromPercent } from '../amounts';
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
return { value: value ?? 0, currencyCode: currency };
@@ -20,6 +21,16 @@ function num(value: unknown): number | undefined {
return Number.isFinite(n) ? n : undefined;
}
/**
* Briox exposes the ex-VAT amount as `net_amount`, which the list payload has
* been observed to omit. It used to fall back to the gross total, making the
* derived VAT 0 on any invoice missing the field. The VAT total itself is
* spelled inconsistently across Briox's endpoints, so several candidates are
* tried; none matching leaves the VAT unknown rather than zero.
*/
const BRIOX_NET_KEYS = ['net_amount'] as const;
const BRIOX_VAT_KEYS = ['vat_amount', 'total_vat', 'vat'] as const;
/**
* Single source of truth for "is this invoice fully settled?", used by BOTH
* deriveInvoiceStatus and the paymentStatus.paid flag so they can never
@@ -80,21 +91,38 @@ export function mapBrioxToSalesInvoice(raw: Record<string, unknown>): SalesInvoi
const rows = (raw['rows'] as Record<string, unknown>[] | undefined) ?? [];
// Line-level amounts arrive from the same string-serializing API as the
// header amounts: coerce ALL numerics through num(), never blind casts.
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
id: String(row['id'] ?? idx + 1),
description: row['description'] as string | undefined,
quantity: num(row['quantity']),
unitCode: row['unit'] as string | undefined,
unitPrice: row['price'] != null ? amount(num(row['price']), currency) : undefined,
lineExtensionAmount: amount(num(row['total']), currency),
taxPercent: num(row['vat_rate']),
accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined,
articleNumber: row['article_number'] as string | undefined,
itemName: row['description'] as string | undefined,
}));
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => {
const lineNet = num(row['total']);
const taxPercent = num(row['vat_rate']);
const lineVat = lineNet !== undefined ? lineVatFromPercent(lineNet, taxPercent) : undefined;
return {
id: String(row['id'] ?? idx + 1),
description: row['description'] as string | undefined,
quantity: num(row['quantity']),
unitCode: row['unit'] as string | undefined,
unitPrice: row['price'] != null ? amount(num(row['price']), currency) : undefined,
lineExtensionAmount: amount(lineNet, currency),
taxPercent,
// Briox states the rate per row but not the money; the migration needs
// the money, because the booking engine sums per-line VAT to post 2611.
taxAmount: lineVat !== undefined ? amount(lineVat, currency) : undefined,
accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined,
articleNumber: row['article_number'] as string | undefined,
itemName: row['description'] as string | undefined,
};
});
const vat = resolveVatTriple({
gross: total,
net: readNumber(raw, BRIOX_NET_KEYS),
vat: readNumber(raw, BRIOX_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(num(raw['net_amount']) ?? total, currency),
// Undefined when `net_amount` is absent: falling back to the gross records
// the whole invoice as its own net, and 0 kr of VAT alongside it.
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
@@ -117,6 +145,7 @@ export function mapBrioxToSalesInvoice(raw: Record<string, unknown>): SalesInvoi
raw['customer_org_number'] as string | undefined,
),
lines,
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
paymentTerms: raw['payment_terms'] as string | undefined,
@@ -148,8 +177,16 @@ export function mapBrioxToSupplierInvoice(raw: Record<string, unknown>): Supplie
accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined,
}));
const vat = resolveVatTriple({
gross: total,
net: readNumber(raw, BRIOX_NET_KEYS),
vat: readNumber(raw, BRIOX_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(num(raw['net_amount']) ?? total, currency),
// Undefined when `net_amount` is absent: falling back to the gross records
// the whole invoice as its own net, and 0 kr of VAT alongside it.
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
@@ -172,6 +209,7 @@ export function mapBrioxToSupplierInvoice(raw: Record<string, unknown>): Supplie
),
buyer: buildParty(''),
lines,
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
ocrNumber: raw['ocr'] as string | undefined,
+11 -1
View File
@@ -108,7 +108,17 @@ export interface PaginatedResponse<T> {
export type InvoiceStatusCode = 'draft' | 'sent' | 'booked' | 'paid' | 'overdue' | 'cancelled' | 'credited';
export interface LegalMonetaryTotalDto {
lineExtensionAmount: AmountType;
/**
* Sum of the line amounts, excluding VAT.
*
* Optional because several providers omit it from their list payloads
* (Fortnox `Net`, Briox `net_amount`) and one never exposes it at all.
* Absent means "the net was not established", NOT "the net equals the
* gross": mappers must leave it undefined rather than fall back to
* `payableAmount`, which silently turns every such invoice into a 0 kr VAT
* record that still balances and so goes unnoticed.
*/
lineExtensionAmount?: AmountType;
taxExclusiveAmount?: AmountType;
taxInclusiveAmount?: AmountType;
allowanceTotalAmount?: AmountType;
@@ -0,0 +1,101 @@
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);
});
});
+77 -23
View File
@@ -9,6 +9,24 @@ import type {
PaymentDto,
AmountType, PartyDto,
} from '../dto';
import { readNumber, resolveVatTriple, lineVatFromPercent } from '../amounts';
/**
* Fortnox splits its invoice payloads in two. `GET /3/invoices` answers with
* the short form (`InvoiceShort`): DocumentNumber, dates, customer, `Total`,
* `Balance` and the status flags, but no `Net`, no `TotalVAT` and no
* `InvoiceRows`. Those three live only on the detail form (`InvoiceFull`)
* behind `GET /3/invoices/{DocumentNumber}`.
*
* The migration used to map the list form alone, so `Net` was always absent
* and defaulted to `Total`; VAT, derived as gross minus net, came out 0 on
* every migrated Fortnox invoice. Hydrating the detail (see
* `hydrateSalesInvoiceDetails` in provider-data-fetcher.ts) is what makes
* these fields available; the readers below keep "absent" distinguishable
* from "zero" for the invoices that are not hydrated.
*/
const FORTNOX_NET_KEYS = ['Net'] as const;
const FORTNOX_VAT_KEYS = ['TotalVAT'] as const;
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
return { value: value ?? 0, currencyCode: currency };
@@ -101,21 +119,41 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
const balance = paid ? 0 : ((raw['Balance'] as number | undefined) ?? total);
const rows = (raw['InvoiceRows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
id: String(row['RowId'] ?? idx + 1),
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,
lineExtensionAmount: amount(row['Total'] as number ?? 0, currency),
taxPercent: row['VAT'] as number | undefined,
accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
articleNumber: row['ArticleNumber'] as string | undefined,
itemName: row['Description'] as string | 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;
const taxPercent = readNumber(row, ['VAT']);
const lineVat = lineVatFromPercent(lineNet, taxPercent);
return {
id: String(row['RowId'] ?? idx + 1),
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,
lineExtensionAmount: amount(lineNet, currency),
taxPercent,
// Fortnox states the rate per row but not the money. Deriving it here is
// what lets the migration write a per-line vat_amount: the booking engine
// sums those to post 2611, so a line left at 0 posts no output VAT.
taxAmount: lineVat !== undefined ? amount(lineVat, currency) : undefined,
accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
articleNumber: row['ArticleNumber'] as string | undefined,
itemName: row['Description'] as string | undefined,
};
});
const vat = resolveVatTriple({
gross: total,
net: readNumber(raw, FORTNOX_NET_KEYS),
vat: readNumber(raw, FORTNOX_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(raw['Net'] as number ?? total, currency),
// Undefined when the payload is the list form: the net was not stated and
// must not be assumed equal to the gross.
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
@@ -142,6 +180,7 @@ export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInv
raw as Record<string, unknown>,
),
lines,
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
paymentTerms: raw['TermsOfPayment'] as string | undefined,
@@ -167,18 +206,32 @@ export function mapFortnoxToSupplierInvoice(raw: Record<string, unknown>): Suppl
const balance = paid ? 0 : ((raw['Balance'] as number | undefined) ?? total);
const rows = (raw['SupplierInvoiceRows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({
id: String(row['RowId'] ?? idx + 1),
description: row['Description'] as string | undefined,
quantity: row['Quantity'] as number | undefined,
unitPrice: row['Price'] != null ? amount(row['Price'] as number, currency) : undefined,
lineExtensionAmount: amount(row['Total'] as number ?? 0, currency),
accountNumber: row['Account'] != null ? String(row['Account']) : undefined,
articleNumber: row['ArticleNumber'] as string | undefined,
}));
const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => {
const lineNet = readNumber(row, ['Total']) ?? 0;
const taxPercent = readNumber(row, ['VAT']);
const lineVat = lineVatFromPercent(lineNet, taxPercent);
return {
id: String(row['RowId'] ?? idx + 1),
description: row['Description'] as string | undefined,
quantity: row['Quantity'] as number | undefined,
unitPrice: row['Price'] != null ? amount(row['Price'] as number, currency) : undefined,
lineExtensionAmount: amount(lineNet, currency),
taxPercent,
taxAmount: lineVat !== undefined ? amount(lineVat, currency) : undefined,
accountNumber: row['Account'] != null ? String(row['Account']) : undefined,
articleNumber: row['ArticleNumber'] as string | undefined,
};
});
const vat = resolveVatTriple({
gross: total,
net: readNumber(raw, FORTNOX_NET_KEYS),
vat: readNumber(raw, FORTNOX_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(raw['Net'] as number ?? total, currency),
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
@@ -201,6 +254,7 @@ export function mapFortnoxToSupplierInvoice(raw: Record<string, unknown>): Suppl
),
buyer: buildParty(''),
lines,
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
ocrNumber: raw['OCR'] as string | undefined,
+394
View File
@@ -343,3 +343,397 @@ export async function fetchSupplierInvoicesDirect(
return [];
}
// ── Detail hydration ────────────────────────────────────────────────
//
// Every provider config has always declared a `detailEndpoint`, and nothing
// ever called one: invoices were mapped from the LIST payload alone. For
// Fortnox that payload is the short form, which omits `Net`, `TotalVAT` and
// `InvoiceRows` entirely, so the migration wrote 8 700+ invoices carrying a
// 25 % label and 0 kr of VAT, with no line items behind them. Briox omits its
// net the same way, and Björn Lundén ships no line items in a list response
// at all.
//
// Hydration closes that hole by fetching the detail form for the invoices
// that need it. It is bounded, because the volume is real: the largest
// migrated company holds 1 911 invoices and Fortnox allows 4 requests per
// second, so hydrating everything would take ~8 minutes against a 300 s
// function ceiling. Two properties keep it safe:
//
// 1. Open invoices are hydrated FIRST. They are the ones that can still
// reach the ledger (a payment match books revenue and VAT off these
// numbers, and crediting one posts a reversal), and there are few of
// them: at most 71 per company across the migrated set.
// 2. Whatever the budget does not cover is REPORTED, never silently
// dropped. A migration that hydrated 300 of 1 900 invoices says so.
/** The two resources hydration applies to. */
type InvoiceResource =
| typeof ResourceType.SalesInvoices
| typeof ResourceType.SupplierInvoices;
/** What a hydration pass managed to do, for the migration summary. */
export interface HydrationReport {
/** Invoices whose payload was missing VAT, a net, or line items. */
needed: number;
/** Detail payloads successfully fetched and re-mapped. */
hydrated: number;
/** Detail fetches that errored; the list-form invoice was kept. */
failed: number;
/** Needed but not attempted because the time budget ran out. */
skippedForBudget: number;
/**
* Set when hydration stopped early. `auth` means the provider rejected the
* token or the scope, so every remaining call would fail the same way and
* issuing them would just burn the shared rate-limit budget; `budget` means
* the clock ran out. Absent when the pass ran to completion.
*/
abortedBy?: 'auth' | 'budget';
}
const EMPTY_HYDRATION_REPORT: HydrationReport = {
needed: 0, hydrated: 0, failed: 0, skippedForBudget: 0,
};
/**
* Default wall-clock ceiling for one hydration pass.
*
* The migration route runs under `maxDuration = 300`, and hydration is one
* step among many (customers, suppliers, invoices, SIE, documents). 90 s
* covers every open invoice in the migrated set several times over at
* Fortnox's 4 req/s while leaving the rest of the run its share.
*/
const DEFAULT_HYDRATION_BUDGET_MS = 90_000;
/** Parallel detail fetches. The per-client token bucket is the real limit. */
const HYDRATION_CONCURRENCY = 3;
/**
* Does this invoice still have something to gain from its detail payload?
*
* An invoice that already carries a VAT total, a net and its lines was fully
* described by the list payload (Bokio, WINT) and is left alone: hydrating it
* would spend a request to learn nothing.
*/
function salesInvoiceNeedsDetail(dto: SalesInvoiceDto): boolean {
return dto.taxTotal === undefined
|| dto.legalMonetaryTotal.lineExtensionAmount === undefined
|| dto.lines.length === 0;
}
function supplierInvoiceNeedsDetail(dto: SupplierInvoiceDto): boolean {
return dto.taxTotal === undefined
|| dto.legalMonetaryTotal.lineExtensionAmount === undefined
|| dto.lines.length === 0;
}
/**
* Fetch one raw detail payload, or null when the provider cannot serve one.
*
* Returns a closure rather than taking the provider on every call so the
* per-provider branch is resolved once, and so a provider that cannot hydrate
* (Bokio and BL need a company id; WINT has no supplier endpoint) is
* detectable before any work starts.
*/
type DetailFetch = (dto: { id: string; _raw?: Record<string, unknown> })
=> Promise<Record<string, unknown> | null>;
/**
* The id the DETAIL endpoint expects, which is not always `dto.id`.
*
* Each config names its own `idField`, and Björn Lundén's sales config names
* `invoiceNumber` while its mapper sets `dto.id` from `entityId`: passing the
* DTO id there would request a different invoice, or none. The config is the
* authority, so the raw payload is read through it and `dto.id` is only the
* fallback for a payload that did not survive mapping.
*/
function detailId(dto: { id: string; _raw?: Record<string, unknown> }, idField: string): string {
const raw = dto._raw?.[idField];
return raw !== undefined && raw !== null && raw !== '' ? String(raw) : dto.id;
}
function detailFetcher(
provider: ProviderName,
resource: InvoiceResource,
accessToken: string,
providerCompanyId?: string,
): DetailFetch | null {
const path = (endpoint: string, id: string) =>
endpoint.replace('{id}', encodeURIComponent(id));
if (provider === 'fortnox') {
const config = FORTNOX_RESOURCE_CONFIGS[resource];
if (!config) return null;
return async (dto) => {
const response = await fortnoxClient.get<Record<string, unknown>>(
accessToken, path(config.detailEndpoint, detailId(dto, config.idField)),
);
// Fortnox wraps the detail in a single-key envelope ("Invoice", …).
const body = response[config.detailKey];
return (body as Record<string, unknown> | undefined) ?? null;
};
}
if (provider === 'visma') {
const config = VISMA_RESOURCE_CONFIGS[resource];
if (!config) return null;
return async (dto) => vismaClient.get<Record<string, unknown>>(
accessToken, path(config.detailEndpoint, detailId(dto, config.idField)),
);
}
if (provider === 'briox') {
const config = BRIOX_RESOURCE_CONFIGS[resource];
if (!config) return null;
return async (dto) => {
const response = await brioxClient.get<Record<string, unknown>>(
accessToken, path(config.detailEndpoint, detailId(dto, config.idField)),
);
// Briox wraps some detail bodies and returns others bare.
const body = config.detailKey ? response[config.detailKey] : response;
return (body as Record<string, unknown> | undefined) ?? null;
};
}
if (provider === 'bokio') {
const config = BOKIO_RESOURCE_CONFIGS[resource];
if (!config || !providerCompanyId) return null;
return async (dto) => bokioClient.getDetail<Record<string, unknown>>(
accessToken, providerCompanyId, path(config.detailEndpoint, detailId(dto, config.idField)),
);
}
if (provider === 'bjornlunden') {
const config = BL_RESOURCE_CONFIGS[resource];
if (!config || !providerCompanyId) return null;
return async (dto) => bjornLundenClient.getDetail<Record<string, unknown>>(
accessToken, providerCompanyId, path(config.detailEndpoint, detailId(dto, config.idField)),
);
}
if (provider === 'wint') {
const config = WINT_RESOURCE_CONFIGS[resource];
if (!config) return null;
return async (dto) => wintClient.get<Record<string, unknown>>(
accessToken, path(config.detailEndpoint, detailId(dto, config.idField)),
);
}
return null;
}
/** Resolve the mapper for a provider/resource pair, or null if unsupported. */
function resourceMapper(
provider: ProviderName,
resource: InvoiceResource,
): ((raw: Record<string, unknown>) => unknown) | null {
const configs: Partial<Record<InvoiceResource, { mapper: (raw: Record<string, unknown>) => unknown }>> = {
fortnox: FORTNOX_RESOURCE_CONFIGS,
visma: VISMA_RESOURCE_CONFIGS,
briox: BRIOX_RESOURCE_CONFIGS,
bokio: BOKIO_RESOURCE_CONFIGS,
bjornlunden: BL_RESOURCE_CONFIGS,
wint: WINT_RESOURCE_CONFIGS,
}[provider];
return configs?.[resource]?.mapper ?? null;
}
/** Run `fn` over `items` with at most `limit` in flight. */
async function mapWithConcurrency<T>(
items: T[],
limit: number,
fn: (item: T) => Promise<void>,
): Promise<void> {
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
for (;;) {
const index = cursor++;
if (index >= items.length) return;
await fn(items[index]);
}
});
await Promise.all(workers);
}
/**
* Replace list-form invoices with their detail form, open ones first.
*
* Returns a NEW array in the original order; entries that were not hydrated
* (already complete, out of budget, or the fetch failed) are the originals,
* so the caller never ends up with fewer invoices than it passed in.
*/
async function hydrateInvoices<T extends SalesInvoiceDto | SupplierInvoiceDto>(
items: T[],
needsDetail: (dto: T) => boolean,
fetchDetail: DetailFetch | null,
mapper: ((raw: Record<string, unknown>) => unknown) | null,
label: string,
budgetMs: number,
): Promise<{ items: T[]; report: HydrationReport }> {
if (!fetchDetail || !mapper) return { items, report: { ...EMPTY_HYDRATION_REPORT } };
const pending = items
.map((dto, index) => ({ dto, index }))
.filter(({ dto }) => needsDetail(dto) && dto.id);
if (pending.length === 0) return { items, report: { ...EMPTY_HYDRATION_REPORT } };
// Unpaid invoices are the ones a later payment match or credit note will
// book, so they get the budget first.
pending.sort((a, b) => Number(a.dto.paymentStatus.paid) - Number(b.dto.paymentStatus.paid));
const hydrated = [...items];
const report: HydrationReport = { ...EMPTY_HYDRATION_REPORT, needed: pending.length };
const deadline = Date.now() + budgetMs;
let aborted: 'auth' | 'budget' | null = null;
await mapWithConcurrency(pending, HYDRATION_CONCURRENCY, async ({ dto, index }) => {
if (aborted) {
report.skippedForBudget++;
return;
}
if (Date.now() >= deadline) {
aborted = 'budget';
report.skippedForBudget++;
return;
}
try {
// Race the clock as well as checking it beforehand. The provider clients
// retry 429s and 5xx with backoff (Fortnox: 6 attempts, up to 60 s
// apart), so a call that starts one millisecond inside the budget can
// still be retrying minutes later. Without this bound, three concurrent
// calls hitting a rate-limit wall would hold the whole migration past
// its 300 s function ceiling. The underlying request is not cancelled,
// but control returns and the remaining invoices are reported as
// unhydrated instead of the run dying.
const raw = await withDeadline(fetchDetail(dto), deadline);
if (!raw) {
report.failed++;
return;
}
hydrated[index] = mapper(raw) as T;
report.hydrated++;
} catch (err) {
report.failed++;
if (err instanceof HydrationDeadlineError) {
aborted = 'budget';
return;
}
// A rejected token or a missing scope fails identically for every
// remaining invoice. Issuing hundreds more doomed calls would spend the
// rate-limit budget (shared platform-wide, see acquire()) for nothing
// and bury the real cause under a wall of identical warnings.
if (isAuthFailure(err)) {
aborted = 'auth';
console.warn(
`[provider-data-fetcher] ${label} hydration stopped: provider rejected the token or scope`,
err instanceof Error ? err.message : String(err),
);
return;
}
// Anything else is per-invoice: the list form is incomplete, not wrong,
// so the invoice is kept and the shortfall reported.
console.warn(
`[provider-data-fetcher] ${label} detail fetch failed for ${dto.id}:`,
err instanceof Error ? err.message : String(err),
);
}
});
if (aborted) report.abortedBy = aborted;
console.log(
`[provider-data-fetcher] ${label} hydration: ${report.hydrated}/${report.needed} hydrated, `
+ `${report.failed} failed, ${report.skippedForBudget} not attempted`
+ (aborted ? ` (stopped early: ${aborted})` : ''),
);
return { items: hydrated, report };
}
/** Thrown when a detail fetch is still outstanding at the budget deadline. */
class HydrationDeadlineError extends Error {
constructor() {
super('Hydration budget exhausted while a detail fetch was in flight');
this.name = 'HydrationDeadlineError';
}
}
/** Resolve `promise`, or reject with HydrationDeadlineError at `deadline`. */
function withDeadline<T>(promise: Promise<T>, deadline: number): Promise<T> {
const remaining = deadline - Date.now();
if (remaining <= 0) return Promise.reject(new HydrationDeadlineError());
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new HydrationDeadlineError()), remaining);
promise.then(
(value) => { clearTimeout(timer); resolve(value); },
(err) => { clearTimeout(timer); reject(err); },
);
});
}
/**
* Does this error mean the credential itself is refused?
*
* Every provider client throws its own error class carrying `statusCode`, so
* the shape is read structurally rather than by instanceof across six classes.
* 401 and 403 are the credential answers; a 404 is about one invoice and must
* not stop the pass.
*/
function isAuthFailure(err: unknown): boolean {
const status = (err as { statusCode?: unknown } | null)?.statusCode;
return status === 401 || status === 403;
}
/**
* Sales invoices with their detail payloads merged in where needed.
*
* Separate from `fetchSalesInvoicesDirect` so callers that only need the
* register (a connection test, a count) keep paying one request.
*/
export async function fetchSalesInvoicesHydrated(
provider: ProviderName,
accessToken: string,
providerCompanyId?: string,
budgetMs: number = DEFAULT_HYDRATION_BUDGET_MS,
): Promise<{ invoices: SalesInvoiceDto[]; hydration: HydrationReport }> {
const invoices = await fetchSalesInvoicesDirect(provider, accessToken, providerCompanyId);
const { items, report } = await hydrateInvoices<SalesInvoiceDto>(
invoices,
salesInvoiceNeedsDetail,
detailFetcher(provider, ResourceType.SalesInvoices, accessToken, providerCompanyId),
resourceMapper(provider, ResourceType.SalesInvoices),
`${provider} sales-invoice`,
budgetMs,
);
return { invoices: items, hydration: report };
}
/** Supplier invoices with their detail payloads merged in where needed. */
export async function fetchSupplierInvoicesHydrated(
provider: ProviderName,
accessToken: string,
providerCompanyId?: string,
budgetMs: number = DEFAULT_HYDRATION_BUDGET_MS,
): Promise<{ invoices: SupplierInvoiceDto[]; hydration: HydrationReport }> {
const invoices = await fetchSupplierInvoicesDirect(provider, accessToken, providerCompanyId);
const { items, report } = await hydrateInvoices<SupplierInvoiceDto>(
invoices,
supplierInvoiceNeedsDetail,
detailFetcher(provider, ResourceType.SupplierInvoices, accessToken, providerCompanyId),
resourceMapper(provider, ResourceType.SupplierInvoices),
`${provider} supplier-invoice`,
budgetMs,
);
return { invoices: items, hydration: report };
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { mapVismaToSalesInvoice, mapVismaToSupplierInvoice } from '../mapper';
/**
* eAccounting's `TotalAmount` INCLUDES VAT. It used to be reported as the
* ex-VAT `lineExtensionAmount`, so every migrated Visma invoice recorded its
* gross as its net and 0 kr of VAT. The row reader asked for `LineTotal` and
* `VatRatePercent`, neither of which is in the schema, so lines landed at 0
* with a hardcoded 25 %.
*/
describe('mapVismaToSalesInvoice: VAT', () => {
const base = {
Id: 'abc',
InvoiceNumber: 10205,
InvoiceDate: '2024-02-29',
CurrencyCode: 'SEK',
TotalAmount: 106462.5,
TotalVatAmount: 21292.5,
InvoiceCustomerName: 'Kund AB',
};
it('never reports the VAT-inclusive TotalAmount as the net', () => {
const dto = mapVismaToSalesInvoice(base);
expect(dto.legalMonetaryTotal.payableAmount.value).toBe(106462.5);
expect(dto.legalMonetaryTotal.lineExtensionAmount?.value).toBe(85170);
expect(dto.taxTotal?.taxAmount.value).toBe(21292.5);
});
it('reads AmountNoVat and PercentVat from the rows', () => {
const dto = mapVismaToSalesInvoice({
...base,
Rows: [
{ LineNumber: 1, Text: 'Konsulttjänst', Quantity: 1, UnitPrice: 85170, AmountNoVat: 85170, PercentVat: 25 },
],
});
expect(dto.lines[0]?.lineExtensionAmount.value).toBe(85170);
expect(dto.lines[0]?.taxPercent).toBe(25);
expect(dto.lines[0]?.taxAmount?.value).toBe(21292.5);
});
it('falls back to unit price x quantity when AmountNoVat is absent', () => {
const dto = mapVismaToSalesInvoice({
...base,
Rows: [{ LineNumber: 1, Quantity: 2, UnitPrice: 500, PercentVat: 25 }],
});
expect(dto.lines[0]?.lineExtensionAmount.value).toBe(1000);
expect(dto.lines[0]?.taxAmount?.value).toBe(250);
});
it('leaves the net unknown when the payload states no VAT total', () => {
const { TotalVatAmount: _omitted, ...withoutVat } = base;
const dto = mapVismaToSalesInvoice(withoutVat);
expect(dto.legalMonetaryTotal.lineExtensionAmount).toBeUndefined();
expect(dto.taxTotal).toBeUndefined();
});
});
describe('mapVismaToSupplierInvoice: VAT', () => {
it('reads TotalVatAmount rather than treating the gross as the net', () => {
const dto = mapVismaToSupplierInvoice({
Id: 'sup-1',
InvoiceNumber: 500,
InvoiceDate: '2025-01-15',
CurrencyCode: 'SEK',
TotalAmount: 1250,
TotalVatAmount: 250,
SupplierName: 'Leverantör AB',
PaymentStatus: 3,
});
expect(dto.legalMonetaryTotal.lineExtensionAmount?.value).toBe(1000);
expect(dto.taxTotal?.taxAmount.value).toBe(250);
});
});
+66 -13
View File
@@ -8,11 +8,37 @@ import type {
CompanyInformationDto,
AmountType, PartyDto,
} from '../dto';
import {
readNumber,
resolveVatTriple,
lineVatFromPercent,
multiplyIfBothPresent,
} from '../amounts';
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
return { value: value ?? 0, currencyCode: currency };
}
/**
* eAccounting states the VAT total on the invoice header (`TotalVatAmount`)
* and the ex-VAT amount per row (`AmountNoVat`), alongside the rate as
* `PercentVat`. None of the three was read: the header net was taken straight
* from `TotalAmount`, which is the amount INCLUDING VAT, so every migrated
* Visma invoice recorded its gross as its net and 0 kr of VAT. The row reader
* asked for `LineTotal` and `VatRatePercent`, neither of which exists in the
* schema, so every line landed with a 0 amount and the hardcoded 25 % default.
*
* `TotalAmount` and `TotalVatAmount` are both in the company's accounting
* currency, while `currencyCode` below reports the INVOICE currency; the
* `*InvoiceCurrency` twins carry the invoice-currency figures. What matters
* for VAT is reading a consistent pair, so both come from the
* accounting-currency family here. The currency-label mismatch on foreign
* invoices predates this change and is deliberately left alone:
* `RemainingAmount` and the paid/balance logic read that same family, and
* switching only the totals would desync payment status.
*/
const VISMA_INVOICE_VAT_KEYS = ['TotalVatAmount'] as const;
// CustomerInvoiceApi.PaymentStatus: 0 = Paid, 1 = Unpaid, 2 = Overdue.
const CUSTOMER_PS_PAID = 0;
const CUSTOMER_PS_OVERDUE = 2;
@@ -108,20 +134,36 @@ export function mapVismaToSalesInvoice(raw: Record<string, unknown>): SalesInvoi
: remaining === 0 && total !== 0;
const rows = (raw['Rows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
id: String(row['LineNumber'] ?? idx + 1),
description: row['Text'] as string | undefined,
quantity: row['Quantity'] as number | undefined,
unitCode: row['UnitAbbreviation'] as string | undefined,
unitPrice: row['UnitPrice'] != null ? amount(row['UnitPrice'] as number, currency) : undefined,
lineExtensionAmount: amount(row['LineTotal'] as number ?? 0, currency),
taxPercent: row['VatRatePercent'] as number | undefined,
accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
articleNumber: row['ArticleNumber'] as string | undefined,
}));
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => {
// `AmountNoVat` is the row amount excluding VAT. Fall back to
// UnitPrice x Quantity only when the schema field is absent, never to 0.
const lineNet = readNumber(row, ['AmountNoVat'])
?? multiplyIfBothPresent(readNumber(row, ['UnitPrice']), readNumber(row, ['Quantity']));
const taxPercent = readNumber(row, ['PercentVat']);
const lineVat = lineNet !== undefined ? lineVatFromPercent(lineNet, taxPercent) : undefined;
return {
id: String(row['LineNumber'] ?? idx + 1),
description: row['Text'] as string | undefined,
quantity: row['Quantity'] as number | undefined,
unitCode: row['UnitAbbreviation'] as string | undefined,
unitPrice: row['UnitPrice'] != null ? amount(row['UnitPrice'] as number, currency) : undefined,
lineExtensionAmount: amount(lineNet ?? 0, currency),
taxPercent,
taxAmount: lineVat !== undefined ? amount(lineVat, currency) : undefined,
accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
articleNumber: row['ArticleNumber'] as string | undefined,
};
});
const vat = resolveVatTriple({
gross: total,
vat: readNumber(raw, VISMA_INVOICE_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(total, currency),
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
@@ -147,6 +189,7 @@ export function mapVismaToSalesInvoice(raw: Record<string, unknown>): SalesInvoi
undefined,
),
lines,
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
createdAt: raw['CreatedUtc'] as string | undefined,
@@ -186,8 +229,17 @@ export function mapVismaToSupplierInvoice(raw: Record<string, unknown>): Supplie
};
});
// Supplier-invoice `Rows` are accounting rows (debit/credit per account),
// not invoice lines, so the VAT total cannot be summed off them: one of the
// rows IS the VAT account. The header field is the only usable source.
const vat = resolveVatTriple({
gross: total,
vat: readNumber(raw, VISMA_INVOICE_VAT_KEYS),
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(total, currency),
lineExtensionAmount: vat.net !== undefined ? amount(vat.net, currency) : undefined,
taxInclusiveAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
@@ -208,6 +260,7 @@ export function mapVismaToSupplierInvoice(raw: Record<string, unknown>): Supplie
supplier: buildParty((raw['SupplierName'] ?? '') as string),
buyer: buildParty(''),
lines,
taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
updatedAt: raw['ModifiedUtc'] as string | undefined,
+1 -1
View File
@@ -36,7 +36,7 @@ describe('WINT mappers', () => {
expect(dto.dueDate).toBe('2026-03-31');
expect(dto.status).toBe('sent');
expect(dto.legalMonetaryTotal.payableAmount.value).toBe(1250);
expect(dto.legalMonetaryTotal.lineExtensionAmount.value).toBe(1000);
expect(dto.legalMonetaryTotal.lineExtensionAmount?.value).toBe(1000);
expect(dto.taxTotal?.taxAmount.value).toBe(250);
expect(dto.customer.name).toBe('Kund AB');
expect(dto.customer.identifications[0]?.id).toBe('556677-8899');
+3 -1
View File
@@ -131,7 +131,9 @@ export function mapWintToSalesInvoice(raw: Record<string, unknown>): SalesInvoic
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(totalTax != null ? round2(total - totalTax) : total, currency),
// Undefined rather than `total` when WINT omits the tax total: reporting
// the gross as the net is what makes an invoice read as 0 kr VAT.
lineExtensionAmount: totalTax != null ? amount(round2(total - totalTax), currency) : undefined,
taxInclusiveAmount: amount(total, currency),
payableAmount: amount(total, currency),
};