diff --git a/DECISIONS.md b/DECISIONS.md index c0a67643..c4271773 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1158,3 +1158,12 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/extensions/general/arcim-migration/__tests__/migration-step-errors.test.ts b/extensions/general/arcim-migration/__tests__/migration-step-errors.test.ts index 142afbb6..59b7ebf2 100644 --- a/extensions/general/arcim-migration/__tests__/migration-step-errors.test.ts +++ b/extensions/general/arcim-migration/__tests__/migration-step-errors.test.ts @@ -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', () => ({ diff --git a/extensions/general/arcim-migration/lib/__tests__/entity-mapper-vat.test.ts b/extensions/general/arcim-migration/lib/__tests__/entity-mapper-vat.test.ts new file mode 100644 index 00000000..3b9f80b1 --- /dev/null +++ b/extensions/general/arcim-migration/lib/__tests__/entity-mapper-vat.test.ts @@ -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 { + 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 { + 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) + }) +}) diff --git a/extensions/general/arcim-migration/lib/entity-mapper.ts b/extensions/general/arcim-migration/lib/entity-mapper.ts index 0b469fc7..ed5552ff 100644 --- a/extensions/general/arcim-migration/lib/entity-mapper.ts +++ b/extensions/general/arcim-migration/lib/entity-mapper.ts @@ -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 items: Record[] 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 = { @@ -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 { +/** + * 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 { + 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 = { 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 { +/** + * 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 { + 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), } } diff --git a/extensions/general/arcim-migration/lib/migration-orchestrator.ts b/extensions/general/arcim-migration/lib/migration-orchestrator.ts index 2b9f81f4..a0cbe173 100644 --- a/extensions/general/arcim-migration/lib/migration-orchestrator.ts +++ b/extensions/general/arcim-migration/lib/migration-orchestrator.ts @@ -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 ({ 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 ({ 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 { + 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(); + }); +}); diff --git a/lib/providers/__tests__/hydrate-invoices.test.ts b/lib/providers/__tests__/hydrate-invoices.test.ts new file mode 100644 index 00000000..ed5e045b --- /dev/null +++ b/lib/providers/__tests__/hydrate-invoices.test.ts @@ -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[]) { + 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); + }); +}); diff --git a/lib/providers/amounts.ts b/lib/providers/amounts.ts new file mode 100644 index 00000000..a7924513 --- /dev/null +++ b/lib/providers/amounts.ts @@ -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 | 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); +} diff --git a/lib/providers/bjornlunden/mapper.ts b/lib/providers/bjornlunden/mapper.ts index be50b0b0..8edd5687 100644 --- a/lib/providers/bjornlunden/mapper.ts +++ b/lib/providers/bjornlunden/mapper.ts @@ -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): 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): 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): 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): 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, diff --git a/lib/providers/bokio/mapper.ts b/lib/providers/bokio/mapper.ts index 1659cdac..736c8aaf 100644 --- a/lib/providers/bokio/mapper.ts +++ b/lib/providers/bokio/mapper.ts @@ -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): 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): SalesInvoi const rawLines = (raw['lineItems'] as Record[] | 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): 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): 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): SupplierDto { export function mapBokioToSupplierInvoice(raw: Record): 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): Supplie const rawLines = (raw['lineItems'] as Record[] | 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): 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): Supplie ), buyer: buildParty(''), lines, + taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined, legalMonetaryTotal, paymentStatus, ocrNumber: raw['ocrNumber'] as string | undefined, diff --git a/lib/providers/briox/mapper.ts b/lib/providers/briox/mapper.ts index 10a9f8ea..5427b5a3 100644 --- a/lib/providers/briox/mapper.ts +++ b/lib/providers/briox/mapper.ts @@ -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): SalesInvoi const rows = (raw['rows'] as Record[] | 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): 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): 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): Supplie ), buyer: buildParty(''), lines, + taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined, legalMonetaryTotal, paymentStatus, ocrNumber: raw['ocr'] as string | undefined, diff --git a/lib/providers/dto.ts b/lib/providers/dto.ts index b4516744..4dcee42e 100644 --- a/lib/providers/dto.ts +++ b/lib/providers/dto.ts @@ -108,7 +108,17 @@ export interface PaginatedResponse { 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; diff --git a/lib/providers/fortnox/__tests__/mapper-vat.test.ts b/lib/providers/fortnox/__tests__/mapper-vat.test.ts new file mode 100644 index 00000000..9ae2e0da --- /dev/null +++ b/lib/providers/fortnox/__tests__/mapper-vat.test.ts @@ -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); + }); +}); diff --git a/lib/providers/fortnox/mapper.ts b/lib/providers/fortnox/mapper.ts index 70e40e33..761b8de7 100644 --- a/lib/providers/fortnox/mapper.ts +++ b/lib/providers/fortnox/mapper.ts @@ -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): SalesInv const balance = paid ? 0 : ((raw['Balance'] as number | undefined) ?? total); const rows = (raw['InvoiceRows'] as Record[] | 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): SalesInv raw as Record, ), 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): Suppl const balance = paid ? 0 : ((raw['Balance'] as number | undefined) ?? total); const rows = (raw['SupplierInvoiceRows'] as Record[] | 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): Suppl ), buyer: buildParty(''), lines, + taxTotal: vat.vat !== undefined ? { taxAmount: amount(vat.vat, currency) } : undefined, legalMonetaryTotal, paymentStatus, ocrNumber: raw['OCR'] as string | undefined, diff --git a/lib/providers/provider-data-fetcher.ts b/lib/providers/provider-data-fetcher.ts index 8e0ddc38..adeff058 100644 --- a/lib/providers/provider-data-fetcher.ts +++ b/lib/providers/provider-data-fetcher.ts @@ -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 }) + => Promise | 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 }, 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>( + 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 | undefined) ?? null; + }; + } + + if (provider === 'visma') { + const config = VISMA_RESOURCE_CONFIGS[resource]; + if (!config) return null; + return async (dto) => vismaClient.get>( + 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>( + 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 | undefined) ?? null; + }; + } + + if (provider === 'bokio') { + const config = BOKIO_RESOURCE_CONFIGS[resource]; + if (!config || !providerCompanyId) return null; + return async (dto) => bokioClient.getDetail>( + 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>( + 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>( + 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) => unknown) | null { + const configs: Partial) => 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( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + 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( + items: T[], + needsDetail: (dto: T) => boolean, + fetchDetail: DetailFetch | null, + mapper: ((raw: Record) => 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(promise: Promise, deadline: number): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) return Promise.reject(new HydrationDeadlineError()); + + return new Promise((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( + 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( + invoices, + supplierInvoiceNeedsDetail, + detailFetcher(provider, ResourceType.SupplierInvoices, accessToken, providerCompanyId), + resourceMapper(provider, ResourceType.SupplierInvoices), + `${provider} supplier-invoice`, + budgetMs, + ); + + return { invoices: items, hydration: report }; +} diff --git a/lib/providers/visma/__tests__/mapper-vat.test.ts b/lib/providers/visma/__tests__/mapper-vat.test.ts new file mode 100644 index 00000000..2034ec72 --- /dev/null +++ b/lib/providers/visma/__tests__/mapper-vat.test.ts @@ -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); + }); +}); diff --git a/lib/providers/visma/mapper.ts b/lib/providers/visma/mapper.ts index 23accd95..2ef1dbf3 100644 --- a/lib/providers/visma/mapper.ts +++ b/lib/providers/visma/mapper.ts @@ -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): SalesInvoi : remaining === 0 && total !== 0; const rows = (raw['Rows'] as Record[] | 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): 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): 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): 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, diff --git a/lib/providers/wint/__tests__/mapper.test.ts b/lib/providers/wint/__tests__/mapper.test.ts index 377e665c..804c6d00 100644 --- a/lib/providers/wint/__tests__/mapper.test.ts +++ b/lib/providers/wint/__tests__/mapper.test.ts @@ -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'); diff --git a/lib/providers/wint/mapper.ts b/lib/providers/wint/mapper.ts index fbe9b259..9090ce23 100644 --- a/lib/providers/wint/mapper.ts +++ b/lib/providers/wint/mapper.ts @@ -131,7 +131,9 @@ export function mapWintToSalesInvoice(raw: Record): 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), };