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
+9
View File
@@ -1158,3 +1158,12 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-21] Peppol access is granted per company by the operators, never self-served (peppol_access table, locked by default): every transmission is billed per document by Qvalia and every receiving identifier consumes a contracted tenant slot, so the company asks from Settings > Fakturering (request row + e-mail to support) and we enable it with scripts/peppol/access.ts, setting max_sends (null = no cap) and separately receive_enabled; the send route refuses PEPPOL_ACCESS_REQUIRED / PEPPOL_SEND_LIMIT_REACHED before touching the invoice, and registration refuses PEPPOL_RECEIVING_NOT_ENABLED. Founder call 2026-08-21 after the first open-for-all hour in prod.
[2026-08-22] BoXon feedback fix: leftover assistant proposals no longer need manual cleanup in Granskning. lib/agent/pending/reject-conversation-pending.ts rejects a conversation's still-pending pending_operations in one update (guarded on status='pending' so it never stamps 'rejected' over a committing/committed verifikat; company-scoped; filtered by agent_metadata->>conversation_id). Surfaced two ways: POST /api/agent/conversations/[id]/reject-pending (the chat's "Rensa förslag som inte godkänts" button) + auto-clear when the conversation is ARCHIVED (best-effort in the PATCH; archive = "I'm done with this thread"). Kept durable-by-default (proposals still resume) — the button is explicit user intent, not an auto-reject on every panel close, so resume still works. Chat button drops all staged cards from view on click (committed ones are already booked; the card was only a confirmation). UI PR: needs founder visual sign-off on the button.
[2026-08-22] OSS support = one `oss` revenue momskod (off every ruta) + SIE-import label suggestion, not per-country invoice rates or an EUR/ECB OSS report: Skatteverket requires OSS sales out of the ordinary momsdeklaration, and the missing piece a Fortnox OSS user hits first is the mapping step (3106-style labels were pushed to ruta 35, OSS-named accounts had no option). Per-country VAT rates on invoices and the quarterly EUR underlag are a separate feature; the recommended interim workflow is one revenue account per country/rate (as Fortnox suggests) + Huvudbok per kvartal + manual ECB conversion.
[2026-08-20] Provider invoice mappers report an absent VAT/net field as `undefined`, never as 0 or as "net equals gross". Fortnox's list payload (`InvoiceShort`) carries no `Net`, `TotalVAT` or `InvoiceRows` (confirmed against the go-fortnox client's struct split), so the old `?? total` fallback derived 0 kr VAT on every migrated Fortnox invoice; measured on prod, 8 712 sales invoices across 43 companies assert a rate with 0 kr of VAT, plus 1 240 supplier invoices. `LegalMonetaryTotalDto.lineExtensionAmount` is now optional to express it.
[2026-08-20] The migration writes `vat_rate: null` rather than 25 when no evidence established a rate, and keeps `vat_treatment` at the schema default instead of widening `Invoice['vat_treatment']` to nullable: the column is read at 90 call sites across 38 files including the booking paths, and a nullable treatment there is a far larger change than the bug warrants. The null rate plus the reported `vatUnresolved` count is the signal.
[2026-08-20] An observed 0 % rate on a SEK invoice maps to `exempt`, not `standard_25` (the old fallback). A momsfri sale labelled 25 % routes to revenue account 3001 and into ruta 05 of the momsdeklaration. A 0 % rate in a foreign currency stays `export`. The source cannot distinguish momsfritt from omvänd skattskyldighet, so currency is the only signal used.
[2026-08-20] A rate divided out of the totals is snapped to a Swedish statutory rate only within 0,5 percentage points; anything else (19 % DE, 24 % FI) leaves `vat_rate` null rather than being coerced. A rate stated on a line always beats one divided out of the header, because a mixed-rate invoice divides out to a blended figure matching no real rate.
[2026-08-20] Invoice detail hydration is bounded by a 90 s budget and fetches OPEN invoices first, rather than hydrating every invoice. The largest migrated company holds 1 911 invoices and Fortnox allows 4 req/s, so full hydration would need ~8 minutes against the route's `maxDuration = 300`; open invoices are both the few (<=71 per company) and the only ones a later payment match or credit note can still book off the wrong numbers. What the budget does not reach is counted into the migration result, never silently dropped.
[2026-08-20] `resolveVatTriple` guarantees `net + vat === gross` even when the provider states all three and they disagree, absorbing the gap into the net rather than the VAT. Fortnox's `Total` is post-öresavrundning while `Net + TotalVAT` is the unrounded `Gross`, so they differ by up to 50 öre; passing both through would leave `subtotal + vat_amount != total` on the invoice row, and the header booking path in invoice-entries.ts derives the 1510 debit from the sum of its credits, so the receivable would sit a few öre off what the customer owes with the verifikat still balancing. The VAT is the figure that must survive intact because it reaches the momsdeklaration.
[2026-08-20] Invoice detail hydration addresses the endpoint by the resource config's own `idField` read off the raw payload, not by `dto.id`. Björn Lundén's sales config names `invoiceNumber` while its mapper builds `dto.id` from `entityId`, so `dto.id` would have requested a different invoice or none; every other provider/resource pair happens to agree, which is exactly why the mismatch was easy to miss.
[2026-08-21] A migrated mixed-rate invoice stores `vat_rate: null` while keeping a treatment, matching what buildInvoiceWriteData already does for a natively created one (`isMixedRate ? null : theRate`). Labelling the header with the first line's rate would assert 25 % on an invoice that is 25 % and 6 %, and dividing the rate out of the totals gives a blended figure matching no statutory rate. The money is unaffected either way: generatePerRateLines groups per ITEM rate, which is why the per-line vat_rate/vat_amount are the part that has to be right.
[2026-08-21] Invoice detail hydration stops the whole pass on a 401/403 and bounds every in-flight call against the budget deadline. The provider clients retry 429s and 5xx with backoff (Fortnox: 6 attempts, up to 60 s apart), so a call starting one millisecond inside the budget can still be retrying minutes later, and three concurrent ones could hold the migration past its 300 s ceiling; racing each against the deadline returns control even though the socket is not cancelled. A rejected token fails identically for every remaining invoice, so continuing would spend the Fortnox rate-limit budget for nothing: note that limiter keys on the literal string 'global', making 4 req/s a PLATFORM-WIDE budget shared by every company and every concurrent migration, not a per-token one.
@@ -31,8 +31,12 @@ vi.mock('@/lib/providers/provider-data-fetcher', () => ({
fetchCompanyInfoDirect: vi.fn(),
fetchCustomersDirect: vi.fn(),
fetchSuppliersDirect: vi.fn(),
fetchSalesInvoicesDirect: vi.fn(),
fetchSupplierInvoicesDirect: vi.fn(),
// The orchestrator consumes the HYDRATED variants: mocking only the
// Direct ones left the invoice steps calling undefined, which the step's
// own try/catch swallowed into a recorded error, so the tests stayed green
// while exercising nothing.
fetchSalesInvoicesHydrated: vi.fn(),
fetchSupplierInvoicesHydrated: vi.fn(),
}))
vi.mock('@/lib/invoices/bulk-reconcile-supplier-vouchers', () => ({
@@ -0,0 +1,304 @@
import { describe, expect, it } from 'vitest'
import { mapSalesInvoice, mapSupplierInvoice } from '../entity-mapper'
import { mapFortnoxToSalesInvoice } from '@/lib/providers/fortnox/mapper'
import type { SalesInvoiceDto, SupplierInvoiceDto } from '@/lib/providers/dto'
const USER = 'user-1'
const COMPANY = 'company-1'
const COUNTERPARTY = 'counterparty-1'
function salesDto(over: Partial<SalesInvoiceDto> = {}): SalesInvoiceDto {
return {
id: '4',
invoiceNumber: '4',
issueDate: '2025-11-01',
dueDate: '2025-12-01',
currencyCode: 'SEK',
status: 'sent',
supplier: { name: '', identifications: [] },
customer: { name: 'Ronaldiniho', identifications: [] },
lines: [],
legalMonetaryTotal: { payableAmount: { value: 1845000, currencyCode: 'SEK' } },
paymentStatus: { paid: false, balance: { value: 1845000, currencyCode: 'SEK' } },
...over,
}
}
function supplierDto(over: Partial<SupplierInvoiceDto> = {}): SupplierInvoiceDto {
return {
id: '77',
invoiceNumber: '77',
issueDate: '2025-11-01',
currencyCode: 'SEK',
status: 'sent',
supplier: { name: 'Leverantör AB', identifications: [] },
buyer: { name: '', identifications: [] },
lines: [],
legalMonetaryTotal: { payableAmount: { value: 1250, currencyCode: 'SEK' } },
paymentStatus: { paid: false, balance: { value: 1250, currencyCode: 'SEK' } },
...over,
}
}
describe('mapSalesInvoice: VAT is observed, never assumed', () => {
it('does not claim 25 % on an invoice whose payload states no VAT', () => {
// The exact reported record: Fortnox list form, 1 845 000 kr, no rows.
// It used to persist as vat_rate 25 / vat_amount 0, a contradiction the
// invoice page rendered as "Momsbehandling: 25 % moms / Moms: 0 kr".
const { invoice, vatUnresolved } = mapSalesInvoice(salesDto(), USER, COMPANY, COUNTERPARTY)
expect(vatUnresolved).toBe(true)
expect(invoice.vat_rate).toBeNull()
expect(invoice.vat_amount).toBe(0)
// Only the gross is known, so it is the only figure asserted.
expect(invoice.total).toBe(1845000)
expect(invoice.subtotal).toBe(1845000)
})
it('splits gross into net and VAT when the provider states the VAT total', () => {
const { invoice, vatUnresolved } = mapSalesInvoice(
salesDto({ taxTotal: { taxAmount: { value: 369000, currencyCode: 'SEK' } } }),
USER, COMPANY, COUNTERPARTY,
)
expect(vatUnresolved).toBe(false)
expect(invoice.subtotal).toBe(1476000)
expect(invoice.vat_amount).toBe(369000)
expect(invoice.vat_rate).toBe(25)
expect(invoice.vat_treatment).toBe('standard_25')
})
it('classifies 12 % and 6 % from the observed ratio', () => {
const twelve = mapSalesInvoice(
salesDto({
legalMonetaryTotal: { payableAmount: { value: 1120, currencyCode: 'SEK' } },
taxTotal: { taxAmount: { value: 120, currencyCode: 'SEK' } },
}),
USER, COMPANY, COUNTERPARTY,
)
expect(twelve.invoice.vat_rate).toBe(12)
expect(twelve.invoice.vat_treatment).toBe('reduced_12')
const six = mapSalesInvoice(
salesDto({
legalMonetaryTotal: { payableAmount: { value: 1060, currencyCode: 'SEK' } },
taxTotal: { taxAmount: { value: 60, currencyCode: 'SEK' } },
}),
USER, COMPANY, COUNTERPARTY,
)
expect(six.invoice.vat_rate).toBe(6)
expect(six.invoice.vat_treatment).toBe('reduced_6')
})
it('calls a genuine 0 % SEK sale momsfritt, not standard_25', () => {
// The old fallback put a momsfri sale on a 25 % treatment, which routes it
// to revenue account 3001 and into ruta 05 of the momsdeklaration.
const { invoice } = mapSalesInvoice(
salesDto({ taxTotal: { taxAmount: { value: 0, currencyCode: 'SEK' } } }),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_rate).toBe(0)
expect(invoice.vat_treatment).toBe('exempt')
})
it('calls a 0 % foreign-currency sale an export', () => {
const { invoice } = mapSalesInvoice(
salesDto({
currencyCode: 'EUR',
taxTotal: { taxAmount: { value: 0, currencyCode: 'EUR' } },
}),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_treatment).toBe('export')
})
it('prefers a rate stated on a line over one divided out of the totals', () => {
const { invoice } = mapSalesInvoice(
salesDto({
taxTotal: { taxAmount: { value: 369000, currencyCode: 'SEK' } },
lines: [{
id: '1',
lineExtensionAmount: { value: 1476000, currencyCode: 'SEK' },
taxPercent: 25,
}],
}),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_rate).toBe(25)
})
it('tolerates provider rounding when dividing the rate out', () => {
// 0,2499… must still read as a 25 % invoice.
const { invoice } = mapSalesInvoice(
salesDto({
legalMonetaryTotal: { payableAmount: { value: 1249.99, currencyCode: 'SEK' } },
taxTotal: { taxAmount: { value: 249.99, currencyCode: 'SEK' } },
}),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_rate).toBe(25)
})
it('leaves the rate null for a non-Swedish rate rather than snapping it', () => {
// 19 % (DE) is a real rate; forcing it to 25 would misstate the invoice.
const { invoice } = mapSalesInvoice(
salesDto({
currencyCode: 'EUR',
legalMonetaryTotal: { payableAmount: { value: 1190, currencyCode: 'EUR' } },
taxTotal: { taxAmount: { value: 190, currencyCode: 'EUR' } },
}),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_rate).toBeNull()
})
})
describe('mapSalesInvoice: line items carry the VAT the engine books', () => {
it('derives a line amount from the rate when the provider states only the rate', () => {
const { items } = mapSalesInvoice(
salesDto({
taxTotal: { taxAmount: { value: 250, currencyCode: 'SEK' } },
legalMonetaryTotal: { payableAmount: { value: 1250, currencyCode: 'SEK' } },
lines: [{
id: '1',
lineExtensionAmount: { value: 1000, currencyCode: 'SEK' },
taxPercent: 25,
}],
}),
USER, COMPANY, COUNTERPARTY,
)
expect(items[0]).toMatchObject({ line_total: 1000, vat_rate: 25, vat_amount: 250 })
})
it('stores vat_rate null for a mixed-rate invoice, like a native one', () => {
// buildInvoiceWriteData stores `isMixedRate ? null : theRate`. Labelling
// the header with the first line's rate would assert 25 % on an invoice
// that is 25 % and 6 %.
const { invoice, items } = mapSalesInvoice(
salesDto({
legalMonetaryTotal: { payableAmount: { value: 1310, currencyCode: 'SEK' } },
taxTotal: { taxAmount: { value: 310, currencyCode: 'SEK' } },
lines: [
{ id: '1', lineExtensionAmount: { value: 1000, currencyCode: 'SEK' }, taxPercent: 25 },
{ id: '2', lineExtensionAmount: { value: 1000, currencyCode: 'SEK' }, taxPercent: 6 },
],
}),
USER, COMPANY, COUNTERPARTY,
)
expect(invoice.vat_rate).toBeNull()
// The money is per line, which is what the booking engine groups on.
expect(items[0]).toMatchObject({ vat_rate: 25, vat_amount: 250 })
expect(items[1]).toMatchObject({ vat_rate: 6, vat_amount: 60 })
})
it('does not label a line 25 % when nothing established a rate', () => {
const { items } = mapSalesInvoice(
salesDto({
lines: [{ id: '1', lineExtensionAmount: { value: 1000, currencyCode: 'SEK' } }],
}),
USER, COMPANY, COUNTERPARTY,
)
// 0 % beside 0 kr is at least self-consistent; 25 % beside 0 kr is not.
expect(items[0]).toMatchObject({ vat_rate: 0, vat_amount: 0 })
})
})
describe('mapSupplierInvoice: VAT', () => {
it('flags an unresolved payload instead of asserting a rate', () => {
const { invoice, vatUnresolved } = mapSupplierInvoice(
supplierDto(), USER, COMPANY, COUNTERPARTY,
)
expect(vatUnresolved).toBe(true)
expect(invoice.vat_amount).toBe(0)
expect(invoice.subtotal).toBe(1250)
})
it('stores the line rate as a FRACTION, unlike sales items', () => {
const { items } = mapSupplierInvoice(
supplierDto({
taxTotal: { taxAmount: { value: 250, currencyCode: 'SEK' } },
lines: [{
id: '1',
lineExtensionAmount: { value: 1000, currencyCode: 'SEK' },
taxPercent: 25,
}],
}),
USER, COMPANY, COUNTERPARTY,
)
expect(items[0]).toMatchObject({ vat_rate: 0.25, vat_amount: 250 })
})
it('keeps a foreign rate rather than coercing it to a Swedish one', () => {
const { items } = mapSupplierInvoice(
supplierDto({
currencyCode: 'EUR',
lines: [{
id: '1',
lineExtensionAmount: { value: 100, currencyCode: 'EUR' },
taxPercent: 19,
}],
}),
USER, COMPANY, COUNTERPARTY,
)
expect(items[0]).toMatchObject({ vat_rate: 0.19, vat_amount: 19 })
})
})
describe('end to end: the reported Fortnox invoice', () => {
const listPayload = {
DocumentNumber: 4,
InvoiceDate: '2025-11-01',
DueDate: '2025-12-01',
CustomerName: 'Ronaldiniho',
Currency: 'SEK',
Total: 1845000,
Balance: 1845000,
Sent: true,
}
it('list form: imported without a fabricated rate', () => {
const { invoice, vatUnresolved } = mapSalesInvoice(
mapFortnoxToSalesInvoice(listPayload), USER, COMPANY, COUNTERPARTY,
)
expect(vatUnresolved).toBe(true)
expect(invoice.vat_rate).toBeNull()
})
it('detail form: imported with the VAT Fortnox actually stated', () => {
const { invoice, items, vatUnresolved } = mapSalesInvoice(
mapFortnoxToSalesInvoice({
...listPayload,
Net: 1476000,
TotalVAT: 369000,
InvoiceRows: [
{ RowId: 1, Description: 'Konsultarvode', DeliveredQuantity: 1, Price: 1476000, Total: 1476000, VAT: 25 },
],
}),
USER, COMPANY, COUNTERPARTY,
)
expect(vatUnresolved).toBe(false)
expect(invoice).toMatchObject({
subtotal: 1476000,
vat_amount: 369000,
total: 1845000,
vat_rate: 25,
vat_treatment: 'standard_25',
})
expect(items[0]).toMatchObject({ line_total: 1476000, vat_amount: 369000 })
// The identity the old code never checked.
expect((invoice.subtotal as number) + (invoice.vat_amount as number)).toBe(invoice.total)
})
})
@@ -9,6 +9,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { encryptCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
import { normalizeVatRateToFraction } from '@/lib/vat/vat-rate-unit'
import { sumLineVat, lineVatFromPercent } from '@/lib/providers/amounts'
import type { Currency, CustomerType, ExchangeRate, SupplierType, VatTreatment } from '@/types'
import type {
CustomerDto,
@@ -224,18 +225,136 @@ export function inferTypeFromParty(
)
}
function inferVatTreatment(taxPercent?: number, currencyCode?: string): VatTreatment {
if (taxPercent === 25) return 'standard_25'
if (taxPercent === 12) return 'reduced_12'
if (taxPercent === 6) return 'reduced_6'
if (taxPercent === 0 && currencyCode && currencyCode !== 'SEK') return 'export'
return 'standard_25'
/**
* The VAT figures for one imported invoice, and whether they were observed.
*
* `rate` is null when nothing in the payload established one. That is the
* difference this type exists to carry: the old code answered "25" to that
* question and the record then read "25 % moms" beside "0 kr", which is not a
* rounding artefact but a claim the source never made.
*/
interface InvoiceVatResolution {
subtotal: number
vatAmount: number
/** Percent (25 / 12 / 6 / 0), or null when no evidence established it. */
rate: number | null
treatment: VatTreatment
/** True when neither a VAT total, line VAT, nor a net could be found. */
unresolved: boolean
}
function inferVatRate(taxPercent?: number): number {
if (taxPercent === 25 || taxPercent === 12 || taxPercent === 6) return taxPercent
if (taxPercent === 0) return 0
return 25 // Default to standard rate
/** Swedish statutory rates, most common first. */
const SWEDISH_VAT_RATES = [25, 12, 6, 0] as const
/**
* Snap an observed ratio to a statutory Swedish rate.
*
* Providers hand back both units (25 and 0.25) and their own rounding, so an
* invoice whose VAT divided by its net comes to 0.2499 is a 25 % invoice.
* A ratio matching none of the statutory rates returns null rather than the
* nearest one: an unrecognised rate is a fact worth surfacing, and a foreign
* invoice may legitimately carry 19 % or 24 %.
*/
function snapToSwedishRate(ratio: number): number | null {
const percent = ratio > 1 ? ratio : ratio * 100
return SWEDISH_VAT_RATES.find((rate) => Math.abs(percent - rate) < 0.5) ?? null
}
/**
* Treatment implied by an observed rate.
*
* A 0 % rate is genuinely ambiguous in the source data: it could be momsfritt,
* omvänd skattskyldighet or an export. Currency is the only signal available
* here, so a non-SEK invoice reads as export and a SEK one as exempt. Both
* post to a 0 % revenue account, which is what the numbers say; calling it
* `standard_25` (the old fallback) would have put a momsfri sale on 3001 and
* into ruta 05 of the momsdeklaration.
*/
function treatmentForRate(rate: number, currencyCode?: string): VatTreatment {
if (rate === 25) return 'standard_25'
if (rate === 12) return 'reduced_12'
if (rate === 6) return 'reduced_6'
return currencyCode && currencyCode !== 'SEK' ? 'export' : 'exempt'
}
/**
* Establish subtotal / VAT / rate for an imported invoice from evidence only.
*
* Evidence is taken in descending order of authority: the provider's own VAT
* total, then the sum of per-line VAT, then the gap between a stated net and
* the gross. When none of the three exists the invoice is marked unresolved
* and keeps only the figure that IS known, the gross the customer owes; the
* rate goes to null so no downstream reader can mistake silence for 25 %.
*/
function resolveInvoiceVat(
dto: { currencyCode: string; lines: readonly { taxPercent?: number; taxAmount?: { value: number } }[]; taxTotal?: { taxAmount: { value: number } }; legalMonetaryTotal: { lineExtensionAmount?: { value: number }; payableAmount: { value: number } } },
): InvoiceVatResolution {
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
const statedNet = dto.legalMonetaryTotal.lineExtensionAmount?.value
const net = statedNet !== undefined ? round2(statedNet) : undefined
const vatAmount = dto.taxTotal !== undefined
? round2(dto.taxTotal.taxAmount.value)
: sumLineVat(dto.lines) ?? (net !== undefined ? round2(total - net) : undefined)
if (vatAmount === undefined) {
return {
subtotal: total,
vatAmount: 0,
rate: null,
// Nothing was observed, so nothing is asserted: `vat_rate: null` is the
// signal the UI and the repair query read. The treatment column is NOT
// NULL-typed across the codebase, so it keeps the schema default rather
// than widening `Invoice['vat_treatment']` through 90 call sites.
treatment: 'standard_25',
unresolved: true,
}
}
const subtotal = net ?? round2(total - vatAmount)
// A rate stated on a line beats one divided out of the totals: mixed-rate
// invoices divide out to a blended figure that matches no statutory rate.
// Distinct rates actually stated on the lines. A rate stated on a line beats
// one divided out of the totals, because a mixed-rate invoice divides out to
// a blended figure matching no statutory rate at all (25 % goods plus 6 %
// books lands near 21 %).
const statedRates = [...new Set(
dto.lines
.filter((line) => line.taxPercent != null)
.map((line) => snapToSwedishRate(line.taxPercent as number) ?? (line.taxPercent as number)),
)]
// Mixed rates store `vat_rate: null` while keeping a treatment, matching what
// buildInvoiceWriteData does for a natively created mixed invoice
// (`isMixedRate ? null : theRate`). Labelling the whole invoice with its
// first line's rate would assert 25 % on an invoice that is 25 % and 6 %.
// The money is unaffected: the booking engine groups per ITEM rate, which is
// why the per-line vat_rate/vat_amount above have to be right.
const isMixed = statedRates.length > 1
const rate = isMixed
? null
: statedRates.length === 1
? snapToSwedishRate(statedRates[0])
: subtotal > 0
? snapToSwedishRate(vatAmount / subtotal)
: null
// A mixed invoice still needs a treatment for the non-null column; the
// highest stated rate is the one that decides which revenue account the
// no-items fallback would reach for, and it is the safest of the set.
const treatmentRate = isMixed ? Math.max(...statedRates) : rate
return {
subtotal,
vatAmount,
rate,
treatment: treatmentRate !== null
? treatmentForRate(treatmentRate, dto.currencyCode)
: 'standard_25',
// A VAT amount was established; only its rate could not be classified.
unresolved: false,
}
}
// ── Currency conversion ─────────────────────────────────────────────
@@ -417,6 +536,13 @@ export interface MappedInvoice {
invoice: Record<string, unknown>
items: Record<string, unknown>[]
fxUnresolved: FxUnresolved | null
/**
* True when the provider payload established no VAT at all, so the invoice
* carries its gross as its subtotal, 0 kr of VAT and a null rate. Counted
* into the migration summary the same way `fxUnresolved` is, so a run that
* could not establish VAT says so instead of looking clean.
*/
vatUnresolved: boolean
}
// ── Public mappers ──────────────────────────────────────────────────
@@ -489,13 +615,10 @@ export function mapSalesInvoice(
customerId: string,
fxRates?: FxRateIndex
): MappedInvoice {
const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value)
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
const vatAmount = round2(dto.taxTotal?.taxAmount.value ?? (total - subtotal))
// Determine primary VAT treatment from first line with tax
const primaryTaxPercent = dto.lines.find(l => l.taxPercent != null)?.taxPercent
const vatTreatment = inferVatTreatment(primaryTaxPercent, dto.currencyCode)
const vat = resolveInvoiceVat(dto)
const subtotal = vat.subtotal
const vatAmount = vat.vatAmount
// Map Arcim status to Accounted status
const statusMap: Record<string, string> = {
@@ -535,8 +658,11 @@ export function mapSalesInvoice(
vat_amount_sek: toSek(vatAmount, fx.sekFactor),
total,
total_sek: toSek(total, fx.sekFactor),
vat_treatment: vatTreatment,
vat_rate: inferVatRate(primaryTaxPercent),
vat_treatment: vat.treatment,
// null, not 25, when the payload established no rate. The column is
// nullable and defaults to 25; writing the default explicitly is what made
// 8 700+ migrated invoices assert "25 % moms" beside 0 kr of it.
vat_rate: vat.rate,
your_reference: null,
our_reference: null,
notes: dto.note || null,
@@ -548,21 +674,43 @@ export function mapSalesInvoice(
remaining_amount: dto.paymentStatus.paid ? 0 : Math.max(0, round2(dto.paymentStatus.balance.value)),
}
const items = dto.lines.map((line, idx) => mapSalesInvoiceLine(line, idx))
const items = dto.lines.map((line, idx) => mapSalesInvoiceLine(line, idx, vat.rate))
return { invoice, items, fxUnresolved: fx.unresolved }
return { invoice, items, fxUnresolved: fx.unresolved, vatUnresolved: vat.unresolved }
}
function mapSalesInvoiceLine(line: SalesInvoiceLineDto, index: number): Record<string, unknown> {
/**
* One invoice_items row.
*
* `invoiceRate` is the rate resolved for the invoice as a whole, used only
* when the line itself states none. The booking engine sums `vat_amount`
* across items to post 2611, so a line that carried a rate but no amount used
* to contribute nothing: 3 451 of 4 030 migrated items at 25 % hold 0 kr.
* Deriving the amount from whichever rate is known fixes that at the source.
*
* invoice_items.vat_rate is stored as a PERCENT (25), unlike
* supplier_invoice_items.vat_rate which is a fraction (0.25).
*/
function mapSalesInvoiceLine(
line: SalesInvoiceLineDto,
index: number,
invoiceRate: number | null,
): Record<string, unknown> {
const lineTotal = round2(line.lineExtensionAmount.value)
const rate = line.taxPercent != null ? snapToSwedishRate(line.taxPercent) : invoiceRate
const vatAmount = line.taxAmount?.value ?? lineVatFromPercent(lineTotal, rate ?? undefined)
return {
sort_order: index + 1,
description: line.description || line.itemName || '',
quantity: line.quantity || 1,
unit: line.unitCode || 'st',
unit_price: round2(line.unitPrice?.value ?? line.lineExtensionAmount.value),
line_total: round2(line.lineExtensionAmount.value),
vat_rate: inferVatRate(line.taxPercent),
vat_amount: round2(line.taxAmount?.value ?? 0),
line_total: lineTotal,
// 0 rather than the old hardcoded 25 when nothing established a rate: a
// 0 % line beside 0 kr of VAT is at least internally consistent.
vat_rate: rate ?? 0,
vat_amount: round2(vatAmount ?? 0),
}
}
@@ -573,12 +721,11 @@ export function mapSupplierInvoice(
supplierId: string,
fxRates?: FxRateIndex
): MappedInvoice {
const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value)
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
const vatAmount = round2(dto.taxTotal?.taxAmount.value ?? (total - subtotal))
const primaryTaxPercent = dto.lines.find(l => l.taxPercent != null)?.taxPercent
const vatTreatment = inferVatTreatment(primaryTaxPercent, dto.currencyCode)
const vat = resolveInvoiceVat(dto)
const subtotal = vat.subtotal
const vatAmount = vat.vatAmount
const vatTreatment = vat.treatment
const statusMap: Record<string, string> = {
draft: 'registered',
@@ -664,25 +811,43 @@ export function mapSupplierInvoice(
notes: dto.note || null,
}
const items = dto.lines.map((line, idx) => mapSupplierInvoiceLine(line, idx))
const items = dto.lines.map((line, idx) => mapSupplierInvoiceLine(line, idx, vat.rate))
return { invoice, items, fxUnresolved: fx.unresolved }
return { invoice, items, fxUnresolved: fx.unresolved, vatUnresolved: vat.unresolved }
}
function mapSupplierInvoiceLine(line: SupplierInvoiceLineDto, index: number): Record<string, unknown> {
/**
* One supplier_invoice_items row.
*
* `invoiceRate` (percent) is the rate resolved for the invoice as a whole and
* is used only when the line states none: the previous `?? 25` asserted a
* standard rate on every line of every provider that omits per-line VAT, and
* paired it with a 0 kr amount.
*/
function mapSupplierInvoiceLine(
line: SupplierInvoiceLineDto,
index: number,
invoiceRate: number | null,
): Record<string, unknown> {
const lineTotal = round2(line.lineExtensionAmount.value)
// Foreign rates (19 % DE) must survive rather than be snapped to a Swedish
// one, so the line's own percent is used as stated; only the fallback comes
// from the invoice-level resolution.
const percent = line.taxPercent ?? invoiceRate ?? 0
const vatAmount = line.taxAmount?.value ?? lineVatFromPercent(lineTotal, percent)
return {
sort_order: index + 1,
description: line.description || line.itemName || '',
quantity: line.quantity || 1,
unit: line.unitCode || 'st',
unit_price: round2(line.unitPrice?.value ?? line.lineExtensionAmount.value),
line_total: round2(line.lineExtensionAmount.value),
line_total: lineTotal,
account_number: line.accountNumber || '4000', // Default to purchases
// supplier_invoice_items stores decimal fractions (0.25 = 25 %), unlike
// customer invoice_items which store percent; foreign rates (19 % DE)
// must survive as 0.19 rather than be coerced to a Swedish rate.
vat_rate: normalizeVatRateToFraction(line.taxPercent ?? 25),
vat_amount: round2(line.taxAmount?.value ?? 0),
// customer invoice_items which store percent.
vat_rate: normalizeVatRateToFraction(percent),
vat_amount: round2(vatAmount ?? 0),
}
}
@@ -29,8 +29,8 @@ import {
fetchCompanyInfoDirect,
fetchCustomersDirect,
fetchSuppliersDirect,
fetchSalesInvoicesDirect,
fetchSupplierInvoicesDirect,
fetchSalesInvoicesHydrated,
fetchSupplierInvoicesHydrated,
} from '@/lib/providers/provider-data-fetcher'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { createLogger } from '@/lib/logger'
@@ -488,7 +488,11 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (options.importSalesInvoices !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar kundfakturor...', progress: 60 })
try {
const invoices = await fetchSalesInvoicesDirect(provider, accessToken, providerCompanyId)
// Hydrated, not the bare list: the list payload omits VAT, the net
// and the line items for most providers (see provider-data-fetcher).
const { invoices, hydration } = await fetchSalesInvoicesHydrated(
provider, accessToken, providerCompanyId,
)
console.log(`[migration] Sales invoices: ${invoices.length} total`)
// Bulk-load existing invoice numbers once.
@@ -642,6 +646,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
ready.map((r) => ({ currencyCode: r.dto.currencyCode, issueDate: r.dto.issueDate }))
)
let fxUnresolved = 0
let vatUnresolved = 0
// Phase C: chunk-insert invoices + their line items.
for (const batch of chunk(ready, INSERT_CHUNK_SIZE)) {
@@ -677,6 +682,13 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
fxUnresolved++
logFxUnresolved('Sales invoice', mappedBatch[i].dto.invoiceNumber, fx)
}
if (mappedBatch[i].vatUnresolved) {
vatUnresolved++
console.warn(
`[migration] Sales invoice ${mappedBatch[i].dto.invoiceNumber}: no VAT in provider payload; `
+ 'imported with gross as subtotal and a null rate.'
)
}
imported++
}
@@ -690,7 +702,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, errorSample: errorSample ?? undefined }
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, vatUnresolved, hydration, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import sales invoices:', err)
recordStepError(results, 'salesInvoices', err)
@@ -701,7 +713,9 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (options.importSupplierInvoices !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörsfakturor...', progress: 80 })
try {
const invoices = await fetchSupplierInvoicesDirect(provider, accessToken, providerCompanyId)
const { invoices, hydration } = await fetchSupplierInvoicesHydrated(
provider, accessToken, providerCompanyId,
)
console.log(`[migration] Supplier invoices: ${invoices.length} total`)
// Load existing (supplier_invoice_number, supplier_id) pairs once.
@@ -863,14 +877,15 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
ready.map((r) => ({ currencyCode: r.dto.currencyCode, issueDate: r.dto.issueDate }))
)
let fxUnresolved = 0
let vatUnresolved = 0
for (const batch of chunk(ready, INSERT_CHUNK_SIZE)) {
const mappedBatch = batch.map((r) => {
const { invoice, items, fxUnresolved: fx } = mapSupplierInvoice(
const { invoice, items, fxUnresolved: fx, vatUnresolved: vatMissing } = mapSupplierInvoice(
r.dto, userId, companyId, r.supplierId, fxRates
)
invoice.arrival_number = nextArrivalNumber++
return { invoice, items, fxUnresolved: fx, dto: r.dto }
return { invoice, items, fxUnresolved: fx, vatUnresolved: vatMissing, dto: r.dto }
})
const outcome = await insertWithPerRowFallback(
@@ -904,6 +919,13 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
fxUnresolved++
logFxUnresolved('Supplier invoice', mappedBatch[i].dto.invoiceNumber, fx)
}
if (mappedBatch[i].vatUnresolved) {
vatUnresolved++
console.warn(
`[migration] Supplier invoice ${mappedBatch[i].dto.invoiceNumber}: no VAT in provider payload; `
+ 'imported with gross as subtotal.'
)
}
imported++
}
@@ -917,7 +939,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, errorSample: errorSample ?? undefined }
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, vatUnresolved, hydration, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import supplier invoices:', err)
recordStepError(results, 'supplierInvoices', err)
+24 -2
View File
@@ -5,6 +5,8 @@
* instead of being duplicated here.
*/
import type { HydrationReport } from '@/lib/providers/provider-data-fetcher'
// Re-export canonical DTOs used by entity-mapper and migration-orchestrator
export type {
AmountType,
@@ -89,12 +91,32 @@ export interface MigrationStepError {
* set, and the migration reports them here instead of passing them off as
* ordinary imports. Per-invoice detail goes to the server log.
*/
/**
* Per-step outcome for the two invoice registers.
*
* `vatUnresolved` counts invoices whose provider payload established no VAT at
* all; they are imported with the gross as subtotal and a null rate, which is
* the only honest reading of "the source did not say". `hydration` reports how
* many detail payloads were fetched, and how many the time budget could not
* reach, so a partially hydrated run is visible rather than looking complete.
*/
export interface InvoiceStepResult {
total: number
imported: number
skipped: number
skipReasons?: SkipReasons
fxUnresolved?: number
vatUnresolved?: number
hydration?: HydrationReport
errorSample?: string
}
export interface MigrationResults {
companyInfo?: { imported: boolean }
customers?: { total: number; imported: number; updated?: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; fxUnresolved?: number; errorSample?: string }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; fxUnresolved?: number; errorSample?: string }
salesInvoices?: InvoiceStepResult
supplierInvoices?: InvoiceStepResult
/**
* Auto-reconciliation of imported supplier invoices to the GL payment
* vouchers that the separate SIE import already posted. `autoLinked` invoices
+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),
};