fix(providers): stop inventing 25% VAT on migrated invoices (#1745)
* fix(providers): stop inventing 25% VAT on migrated invoices An invoice migrated from Fortnox displayed "Momsbehandling: 25 % moms" next to "Moms: 0 kr", with no line items behind it. It was not a display bug: the record really did hold vat_rate 25 and vat_amount 0. Fortnox answers GET /3/invoices with the short form, which carries no Net, no TotalVAT and no InvoiceRows; those live only on the detail form. The migration mapped the list payload alone, so `Net ?? total` made the net equal the gross, VAT derived as gross minus net came out 0, and with no rows to read a rate from, inferVatTreatment/inferVatRate fell through to their `return 'standard_25'` / `return 25` defaults. The result balanced, so nothing downstream noticed. Measured on prod: 8 712 sales invoices across 43 companies assert a rate beside 0 kr of VAT (286 MSEK of subtotal), plus 1 240 supplier invoices. None are booked, but 263 are still open, and the no-items booking fallback in invoice-entries.ts credits the full gross to 30xx and emits no 2611 line at all. Not Fortnox-only. Visma reported its VAT-inclusive TotalAmount as the ex-VAT amount and read rows via `LineTotal`/`VatRatePercent`, neither of which exists in the eAccounting schema (the real names are AmountNoVat and PercentVat), so its lines all landed at 0. Bjorn Lunden reported the gross as the net with no lines at all. Briox and WINT had the same gross-as-net fallback, and Bokio defaulted a missing totalTax to 0. - lib/providers/amounts.ts: readers that return undefined for an absent field, so "the provider says zero" stays distinct from "did not say" - every mapper: populate taxTotal and per-line taxAmount from what the payload actually states; leave the net undefined when it does not - provider-data-fetcher: hydrate the detail endpoint that every config has always declared and nothing ever called, open invoices first, within a time budget, reporting whatever it could not reach - entity-mapper: derive rate and treatment from evidence; when there is none, write vat_rate null and flag vatUnresolved instead of asserting a standard rate Existing rows are untouched; repairing them needs a separate decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(providers): keep subtotal + VAT equal to the invoice total Providers state net, VAT and gross independently and they need not agree: Fortnox's Total is the amount to pay after öresavrundning while Net + TotalVAT is the unrounded Gross, so the two differ by up to 50 öre. Passing both through as stated put that gap into the invoice row, where subtotal + vat_amount no longer equalled total. The header booking path in invoice-entries.ts derives the 1510 debit from the sum of its credits, so the receivable would land a few öre away from what the customer owes while the verifikat still balanced: the same silent shape as the bug this branch fixes. resolveVatTriple now always returns a pair summing to the gross, keeping the VAT intact (it reaches the momsdeklaration) and absorbing the rounding into the net. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(providers): address invoice detail by the configured idField Hydration built the detail path from dto.id. Björn Lundén's sales config names invoiceNumber as its idField while its mapper builds dto.id from entityId, so BL sales invoices would have been hydrated from the wrong resource, or from none. Every other provider/resource pair happens to agree on the two, which is what made the mismatch easy to miss. The config's idField is the authority, read off the raw payload, with dto.id only as the fallback. The regression test uses BL with entityId 99001 and invoiceNumber 5 so the two cannot coincide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(providers): store vat_rate null for migrated mixed-rate invoices resolveInvoiceVat labelled the header with the first line's rate, so an invoice carrying both 25 % and 6 % lines was recorded as a 25 % invoice. buildInvoiceWriteData already stores isMixedRate ? null : theRate for natively created invoices; migrated ones now match. The money was already right and stays right: generatePerRateLines groups per item rate, so a mixed invoice books 25 % and 6 % separately off the per-line vat_rate/vat_amount this branch fixed. Only the header label was overstating what the source said. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(providers): bound hydration against auth failures and the clock Two failure modes that only appear against a real provider. A 401 or 403 fails identically for every remaining invoice, so the pass now stops on the first one instead of issuing hundreds more doomed calls. That matters more than it looks: TokenBucketRateLimiter keys on the literal string 'global', so Fortnox's 4 req/s is a platform-wide budget shared by every company and every concurrent migration, not a per-token one. A 404 is about one invoice and does not stop the pass. The budget was checked before starting a call but never during one. The clients retry 429s and 5xx with backoff (Fortnox: 6 attempts, up to 60 s apart), so a call starting one millisecond inside the budget could still be retrying minutes later, and three concurrent ones could hold the migration past its 300 s function ceiling. Each call is now raced against the deadline; the socket is not cancelled, but control returns and the remaining invoices are reported unhydrated instead of the run dying. Both outcomes are reported as HydrationReport.abortedBy so a partial pass is visible rather than looking complete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <invoice@arcim.io> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
51c815254a
commit
dc5079a912
@@ -31,8 +31,12 @@ vi.mock('@/lib/providers/provider-data-fetcher', () => ({
|
||||
fetchCompanyInfoDirect: vi.fn(),
|
||||
fetchCustomersDirect: vi.fn(),
|
||||
fetchSuppliersDirect: vi.fn(),
|
||||
fetchSalesInvoicesDirect: vi.fn(),
|
||||
fetchSupplierInvoicesDirect: vi.fn(),
|
||||
// The orchestrator consumes the HYDRATED variants: mocking only the
|
||||
// Direct ones left the invoice steps calling undefined, which the step's
|
||||
// own try/catch swallowed into a recorded error, so the tests stayed green
|
||||
// while exercising nothing.
|
||||
fetchSalesInvoicesHydrated: vi.fn(),
|
||||
fetchSupplierInvoicesHydrated: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invoices/bulk-reconcile-supplier-vouchers', () => ({
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mapSalesInvoice, mapSupplierInvoice } from '../entity-mapper'
|
||||
import { mapFortnoxToSalesInvoice } from '@/lib/providers/fortnox/mapper'
|
||||
import type { SalesInvoiceDto, SupplierInvoiceDto } from '@/lib/providers/dto'
|
||||
|
||||
const USER = 'user-1'
|
||||
const COMPANY = 'company-1'
|
||||
const COUNTERPARTY = 'counterparty-1'
|
||||
|
||||
function salesDto(over: Partial<SalesInvoiceDto> = {}): SalesInvoiceDto {
|
||||
return {
|
||||
id: '4',
|
||||
invoiceNumber: '4',
|
||||
issueDate: '2025-11-01',
|
||||
dueDate: '2025-12-01',
|
||||
currencyCode: 'SEK',
|
||||
status: 'sent',
|
||||
supplier: { name: '', identifications: [] },
|
||||
customer: { name: 'Ronaldiniho', identifications: [] },
|
||||
lines: [],
|
||||
legalMonetaryTotal: { payableAmount: { value: 1845000, currencyCode: 'SEK' } },
|
||||
paymentStatus: { paid: false, balance: { value: 1845000, currencyCode: 'SEK' } },
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function supplierDto(over: Partial<SupplierInvoiceDto> = {}): SupplierInvoiceDto {
|
||||
return {
|
||||
id: '77',
|
||||
invoiceNumber: '77',
|
||||
issueDate: '2025-11-01',
|
||||
currencyCode: 'SEK',
|
||||
status: 'sent',
|
||||
supplier: { name: 'Leverantör AB', identifications: [] },
|
||||
buyer: { name: '', identifications: [] },
|
||||
lines: [],
|
||||
legalMonetaryTotal: { payableAmount: { value: 1250, currencyCode: 'SEK' } },
|
||||
paymentStatus: { paid: false, balance: { value: 1250, currencyCode: 'SEK' } },
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('mapSalesInvoice: VAT is observed, never assumed', () => {
|
||||
it('does not claim 25 % on an invoice whose payload states no VAT', () => {
|
||||
// The exact reported record: Fortnox list form, 1 845 000 kr, no rows.
|
||||
// It used to persist as vat_rate 25 / vat_amount 0, a contradiction the
|
||||
// invoice page rendered as "Momsbehandling: 25 % moms / Moms: 0 kr".
|
||||
const { invoice, vatUnresolved } = mapSalesInvoice(salesDto(), USER, COMPANY, COUNTERPARTY)
|
||||
|
||||
expect(vatUnresolved).toBe(true)
|
||||
expect(invoice.vat_rate).toBeNull()
|
||||
expect(invoice.vat_amount).toBe(0)
|
||||
// Only the gross is known, so it is the only figure asserted.
|
||||
expect(invoice.total).toBe(1845000)
|
||||
expect(invoice.subtotal).toBe(1845000)
|
||||
})
|
||||
|
||||
it('splits gross into net and VAT when the provider states the VAT total', () => {
|
||||
const { invoice, vatUnresolved } = mapSalesInvoice(
|
||||
salesDto({ taxTotal: { taxAmount: { value: 369000, currencyCode: 'SEK' } } }),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(vatUnresolved).toBe(false)
|
||||
expect(invoice.subtotal).toBe(1476000)
|
||||
expect(invoice.vat_amount).toBe(369000)
|
||||
expect(invoice.vat_rate).toBe(25)
|
||||
expect(invoice.vat_treatment).toBe('standard_25')
|
||||
})
|
||||
|
||||
it('classifies 12 % and 6 % from the observed ratio', () => {
|
||||
const twelve = mapSalesInvoice(
|
||||
salesDto({
|
||||
legalMonetaryTotal: { payableAmount: { value: 1120, currencyCode: 'SEK' } },
|
||||
taxTotal: { taxAmount: { value: 120, currencyCode: 'SEK' } },
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
expect(twelve.invoice.vat_rate).toBe(12)
|
||||
expect(twelve.invoice.vat_treatment).toBe('reduced_12')
|
||||
|
||||
const six = mapSalesInvoice(
|
||||
salesDto({
|
||||
legalMonetaryTotal: { payableAmount: { value: 1060, currencyCode: 'SEK' } },
|
||||
taxTotal: { taxAmount: { value: 60, currencyCode: 'SEK' } },
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
expect(six.invoice.vat_rate).toBe(6)
|
||||
expect(six.invoice.vat_treatment).toBe('reduced_6')
|
||||
})
|
||||
|
||||
it('calls a genuine 0 % SEK sale momsfritt, not standard_25', () => {
|
||||
// The old fallback put a momsfri sale on a 25 % treatment, which routes it
|
||||
// to revenue account 3001 and into ruta 05 of the momsdeklaration.
|
||||
const { invoice } = mapSalesInvoice(
|
||||
salesDto({ taxTotal: { taxAmount: { value: 0, currencyCode: 'SEK' } } }),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(invoice.vat_rate).toBe(0)
|
||||
expect(invoice.vat_treatment).toBe('exempt')
|
||||
})
|
||||
|
||||
it('calls a 0 % foreign-currency sale an export', () => {
|
||||
const { invoice } = mapSalesInvoice(
|
||||
salesDto({
|
||||
currencyCode: 'EUR',
|
||||
taxTotal: { taxAmount: { value: 0, currencyCode: 'EUR' } },
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(invoice.vat_treatment).toBe('export')
|
||||
})
|
||||
|
||||
it('prefers a rate stated on a line over one divided out of the totals', () => {
|
||||
const { invoice } = mapSalesInvoice(
|
||||
salesDto({
|
||||
taxTotal: { taxAmount: { value: 369000, currencyCode: 'SEK' } },
|
||||
lines: [{
|
||||
id: '1',
|
||||
lineExtensionAmount: { value: 1476000, currencyCode: 'SEK' },
|
||||
taxPercent: 25,
|
||||
}],
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(invoice.vat_rate).toBe(25)
|
||||
})
|
||||
|
||||
it('tolerates provider rounding when dividing the rate out', () => {
|
||||
// 0,2499… must still read as a 25 % invoice.
|
||||
const { invoice } = mapSalesInvoice(
|
||||
salesDto({
|
||||
legalMonetaryTotal: { payableAmount: { value: 1249.99, currencyCode: 'SEK' } },
|
||||
taxTotal: { taxAmount: { value: 249.99, currencyCode: 'SEK' } },
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(invoice.vat_rate).toBe(25)
|
||||
})
|
||||
|
||||
it('leaves the rate null for a non-Swedish rate rather than snapping it', () => {
|
||||
// 19 % (DE) is a real rate; forcing it to 25 would misstate the invoice.
|
||||
const { invoice } = mapSalesInvoice(
|
||||
salesDto({
|
||||
currencyCode: 'EUR',
|
||||
legalMonetaryTotal: { payableAmount: { value: 1190, currencyCode: 'EUR' } },
|
||||
taxTotal: { taxAmount: { value: 190, currencyCode: 'EUR' } },
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(invoice.vat_rate).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapSalesInvoice: line items carry the VAT the engine books', () => {
|
||||
it('derives a line amount from the rate when the provider states only the rate', () => {
|
||||
const { items } = mapSalesInvoice(
|
||||
salesDto({
|
||||
taxTotal: { taxAmount: { value: 250, currencyCode: 'SEK' } },
|
||||
legalMonetaryTotal: { payableAmount: { value: 1250, currencyCode: 'SEK' } },
|
||||
lines: [{
|
||||
id: '1',
|
||||
lineExtensionAmount: { value: 1000, currencyCode: 'SEK' },
|
||||
taxPercent: 25,
|
||||
}],
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(items[0]).toMatchObject({ line_total: 1000, vat_rate: 25, vat_amount: 250 })
|
||||
})
|
||||
|
||||
it('stores vat_rate null for a mixed-rate invoice, like a native one', () => {
|
||||
// buildInvoiceWriteData stores `isMixedRate ? null : theRate`. Labelling
|
||||
// the header with the first line's rate would assert 25 % on an invoice
|
||||
// that is 25 % and 6 %.
|
||||
const { invoice, items } = mapSalesInvoice(
|
||||
salesDto({
|
||||
legalMonetaryTotal: { payableAmount: { value: 1310, currencyCode: 'SEK' } },
|
||||
taxTotal: { taxAmount: { value: 310, currencyCode: 'SEK' } },
|
||||
lines: [
|
||||
{ id: '1', lineExtensionAmount: { value: 1000, currencyCode: 'SEK' }, taxPercent: 25 },
|
||||
{ id: '2', lineExtensionAmount: { value: 1000, currencyCode: 'SEK' }, taxPercent: 6 },
|
||||
],
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(invoice.vat_rate).toBeNull()
|
||||
// The money is per line, which is what the booking engine groups on.
|
||||
expect(items[0]).toMatchObject({ vat_rate: 25, vat_amount: 250 })
|
||||
expect(items[1]).toMatchObject({ vat_rate: 6, vat_amount: 60 })
|
||||
})
|
||||
|
||||
it('does not label a line 25 % when nothing established a rate', () => {
|
||||
const { items } = mapSalesInvoice(
|
||||
salesDto({
|
||||
lines: [{ id: '1', lineExtensionAmount: { value: 1000, currencyCode: 'SEK' } }],
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
// 0 % beside 0 kr is at least self-consistent; 25 % beside 0 kr is not.
|
||||
expect(items[0]).toMatchObject({ vat_rate: 0, vat_amount: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapSupplierInvoice: VAT', () => {
|
||||
it('flags an unresolved payload instead of asserting a rate', () => {
|
||||
const { invoice, vatUnresolved } = mapSupplierInvoice(
|
||||
supplierDto(), USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(vatUnresolved).toBe(true)
|
||||
expect(invoice.vat_amount).toBe(0)
|
||||
expect(invoice.subtotal).toBe(1250)
|
||||
})
|
||||
|
||||
it('stores the line rate as a FRACTION, unlike sales items', () => {
|
||||
const { items } = mapSupplierInvoice(
|
||||
supplierDto({
|
||||
taxTotal: { taxAmount: { value: 250, currencyCode: 'SEK' } },
|
||||
lines: [{
|
||||
id: '1',
|
||||
lineExtensionAmount: { value: 1000, currencyCode: 'SEK' },
|
||||
taxPercent: 25,
|
||||
}],
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(items[0]).toMatchObject({ vat_rate: 0.25, vat_amount: 250 })
|
||||
})
|
||||
|
||||
it('keeps a foreign rate rather than coercing it to a Swedish one', () => {
|
||||
const { items } = mapSupplierInvoice(
|
||||
supplierDto({
|
||||
currencyCode: 'EUR',
|
||||
lines: [{
|
||||
id: '1',
|
||||
lineExtensionAmount: { value: 100, currencyCode: 'EUR' },
|
||||
taxPercent: 19,
|
||||
}],
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(items[0]).toMatchObject({ vat_rate: 0.19, vat_amount: 19 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('end to end: the reported Fortnox invoice', () => {
|
||||
const listPayload = {
|
||||
DocumentNumber: 4,
|
||||
InvoiceDate: '2025-11-01',
|
||||
DueDate: '2025-12-01',
|
||||
CustomerName: 'Ronaldiniho',
|
||||
Currency: 'SEK',
|
||||
Total: 1845000,
|
||||
Balance: 1845000,
|
||||
Sent: true,
|
||||
}
|
||||
|
||||
it('list form: imported without a fabricated rate', () => {
|
||||
const { invoice, vatUnresolved } = mapSalesInvoice(
|
||||
mapFortnoxToSalesInvoice(listPayload), USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(vatUnresolved).toBe(true)
|
||||
expect(invoice.vat_rate).toBeNull()
|
||||
})
|
||||
|
||||
it('detail form: imported with the VAT Fortnox actually stated', () => {
|
||||
const { invoice, items, vatUnresolved } = mapSalesInvoice(
|
||||
mapFortnoxToSalesInvoice({
|
||||
...listPayload,
|
||||
Net: 1476000,
|
||||
TotalVAT: 369000,
|
||||
InvoiceRows: [
|
||||
{ RowId: 1, Description: 'Konsultarvode', DeliveredQuantity: 1, Price: 1476000, Total: 1476000, VAT: 25 },
|
||||
],
|
||||
}),
|
||||
USER, COMPANY, COUNTERPARTY,
|
||||
)
|
||||
|
||||
expect(vatUnresolved).toBe(false)
|
||||
expect(invoice).toMatchObject({
|
||||
subtotal: 1476000,
|
||||
vat_amount: 369000,
|
||||
total: 1845000,
|
||||
vat_rate: 25,
|
||||
vat_treatment: 'standard_25',
|
||||
})
|
||||
expect(items[0]).toMatchObject({ line_total: 1476000, vat_amount: 369000 })
|
||||
// The identity the old code never checked.
|
||||
expect((invoice.subtotal as number) + (invoice.vat_amount as number)).toBe(invoice.total)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { encryptCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
|
||||
import { normalizeVatRateToFraction } from '@/lib/vat/vat-rate-unit'
|
||||
import { sumLineVat, lineVatFromPercent } from '@/lib/providers/amounts'
|
||||
import type { Currency, CustomerType, ExchangeRate, SupplierType, VatTreatment } from '@/types'
|
||||
import type {
|
||||
CustomerDto,
|
||||
@@ -224,18 +225,136 @@ export function inferTypeFromParty(
|
||||
)
|
||||
}
|
||||
|
||||
function inferVatTreatment(taxPercent?: number, currencyCode?: string): VatTreatment {
|
||||
if (taxPercent === 25) return 'standard_25'
|
||||
if (taxPercent === 12) return 'reduced_12'
|
||||
if (taxPercent === 6) return 'reduced_6'
|
||||
if (taxPercent === 0 && currencyCode && currencyCode !== 'SEK') return 'export'
|
||||
return 'standard_25'
|
||||
/**
|
||||
* The VAT figures for one imported invoice, and whether they were observed.
|
||||
*
|
||||
* `rate` is null when nothing in the payload established one. That is the
|
||||
* difference this type exists to carry: the old code answered "25" to that
|
||||
* question and the record then read "25 % moms" beside "0 kr", which is not a
|
||||
* rounding artefact but a claim the source never made.
|
||||
*/
|
||||
interface InvoiceVatResolution {
|
||||
subtotal: number
|
||||
vatAmount: number
|
||||
/** Percent (25 / 12 / 6 / 0), or null when no evidence established it. */
|
||||
rate: number | null
|
||||
treatment: VatTreatment
|
||||
/** True when neither a VAT total, line VAT, nor a net could be found. */
|
||||
unresolved: boolean
|
||||
}
|
||||
|
||||
function inferVatRate(taxPercent?: number): number {
|
||||
if (taxPercent === 25 || taxPercent === 12 || taxPercent === 6) return taxPercent
|
||||
if (taxPercent === 0) return 0
|
||||
return 25 // Default to standard rate
|
||||
/** Swedish statutory rates, most common first. */
|
||||
const SWEDISH_VAT_RATES = [25, 12, 6, 0] as const
|
||||
|
||||
/**
|
||||
* Snap an observed ratio to a statutory Swedish rate.
|
||||
*
|
||||
* Providers hand back both units (25 and 0.25) and their own rounding, so an
|
||||
* invoice whose VAT divided by its net comes to 0.2499 is a 25 % invoice.
|
||||
* A ratio matching none of the statutory rates returns null rather than the
|
||||
* nearest one: an unrecognised rate is a fact worth surfacing, and a foreign
|
||||
* invoice may legitimately carry 19 % or 24 %.
|
||||
*/
|
||||
function snapToSwedishRate(ratio: number): number | null {
|
||||
const percent = ratio > 1 ? ratio : ratio * 100
|
||||
return SWEDISH_VAT_RATES.find((rate) => Math.abs(percent - rate) < 0.5) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Treatment implied by an observed rate.
|
||||
*
|
||||
* A 0 % rate is genuinely ambiguous in the source data: it could be momsfritt,
|
||||
* omvänd skattskyldighet or an export. Currency is the only signal available
|
||||
* here, so a non-SEK invoice reads as export and a SEK one as exempt. Both
|
||||
* post to a 0 % revenue account, which is what the numbers say; calling it
|
||||
* `standard_25` (the old fallback) would have put a momsfri sale on 3001 and
|
||||
* into ruta 05 of the momsdeklaration.
|
||||
*/
|
||||
function treatmentForRate(rate: number, currencyCode?: string): VatTreatment {
|
||||
if (rate === 25) return 'standard_25'
|
||||
if (rate === 12) return 'reduced_12'
|
||||
if (rate === 6) return 'reduced_6'
|
||||
return currencyCode && currencyCode !== 'SEK' ? 'export' : 'exempt'
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish subtotal / VAT / rate for an imported invoice from evidence only.
|
||||
*
|
||||
* Evidence is taken in descending order of authority: the provider's own VAT
|
||||
* total, then the sum of per-line VAT, then the gap between a stated net and
|
||||
* the gross. When none of the three exists the invoice is marked unresolved
|
||||
* and keeps only the figure that IS known, the gross the customer owes; the
|
||||
* rate goes to null so no downstream reader can mistake silence for 25 %.
|
||||
*/
|
||||
function resolveInvoiceVat(
|
||||
dto: { currencyCode: string; lines: readonly { taxPercent?: number; taxAmount?: { value: number } }[]; taxTotal?: { taxAmount: { value: number } }; legalMonetaryTotal: { lineExtensionAmount?: { value: number }; payableAmount: { value: number } } },
|
||||
): InvoiceVatResolution {
|
||||
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
|
||||
const statedNet = dto.legalMonetaryTotal.lineExtensionAmount?.value
|
||||
const net = statedNet !== undefined ? round2(statedNet) : undefined
|
||||
|
||||
const vatAmount = dto.taxTotal !== undefined
|
||||
? round2(dto.taxTotal.taxAmount.value)
|
||||
: sumLineVat(dto.lines) ?? (net !== undefined ? round2(total - net) : undefined)
|
||||
|
||||
if (vatAmount === undefined) {
|
||||
return {
|
||||
subtotal: total,
|
||||
vatAmount: 0,
|
||||
rate: null,
|
||||
// Nothing was observed, so nothing is asserted: `vat_rate: null` is the
|
||||
// signal the UI and the repair query read. The treatment column is NOT
|
||||
// NULL-typed across the codebase, so it keeps the schema default rather
|
||||
// than widening `Invoice['vat_treatment']` through 90 call sites.
|
||||
treatment: 'standard_25',
|
||||
unresolved: true,
|
||||
}
|
||||
}
|
||||
|
||||
const subtotal = net ?? round2(total - vatAmount)
|
||||
|
||||
// A rate stated on a line beats one divided out of the totals: mixed-rate
|
||||
// invoices divide out to a blended figure that matches no statutory rate.
|
||||
// Distinct rates actually stated on the lines. A rate stated on a line beats
|
||||
// one divided out of the totals, because a mixed-rate invoice divides out to
|
||||
// a blended figure matching no statutory rate at all (25 % goods plus 6 %
|
||||
// books lands near 21 %).
|
||||
const statedRates = [...new Set(
|
||||
dto.lines
|
||||
.filter((line) => line.taxPercent != null)
|
||||
.map((line) => snapToSwedishRate(line.taxPercent as number) ?? (line.taxPercent as number)),
|
||||
)]
|
||||
|
||||
// Mixed rates store `vat_rate: null` while keeping a treatment, matching what
|
||||
// buildInvoiceWriteData does for a natively created mixed invoice
|
||||
// (`isMixedRate ? null : theRate`). Labelling the whole invoice with its
|
||||
// first line's rate would assert 25 % on an invoice that is 25 % and 6 %.
|
||||
// The money is unaffected: the booking engine groups per ITEM rate, which is
|
||||
// why the per-line vat_rate/vat_amount above have to be right.
|
||||
const isMixed = statedRates.length > 1
|
||||
const rate = isMixed
|
||||
? null
|
||||
: statedRates.length === 1
|
||||
? snapToSwedishRate(statedRates[0])
|
||||
: subtotal > 0
|
||||
? snapToSwedishRate(vatAmount / subtotal)
|
||||
: null
|
||||
|
||||
// A mixed invoice still needs a treatment for the non-null column; the
|
||||
// highest stated rate is the one that decides which revenue account the
|
||||
// no-items fallback would reach for, and it is the safest of the set.
|
||||
const treatmentRate = isMixed ? Math.max(...statedRates) : rate
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
vatAmount,
|
||||
rate,
|
||||
treatment: treatmentRate !== null
|
||||
? treatmentForRate(treatmentRate, dto.currencyCode)
|
||||
: 'standard_25',
|
||||
// A VAT amount was established; only its rate could not be classified.
|
||||
unresolved: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Currency conversion ─────────────────────────────────────────────
|
||||
@@ -417,6 +536,13 @@ export interface MappedInvoice {
|
||||
invoice: Record<string, unknown>
|
||||
items: Record<string, unknown>[]
|
||||
fxUnresolved: FxUnresolved | null
|
||||
/**
|
||||
* True when the provider payload established no VAT at all, so the invoice
|
||||
* carries its gross as its subtotal, 0 kr of VAT and a null rate. Counted
|
||||
* into the migration summary the same way `fxUnresolved` is, so a run that
|
||||
* could not establish VAT says so instead of looking clean.
|
||||
*/
|
||||
vatUnresolved: boolean
|
||||
}
|
||||
|
||||
// ── Public mappers ──────────────────────────────────────────────────
|
||||
@@ -489,13 +615,10 @@ export function mapSalesInvoice(
|
||||
customerId: string,
|
||||
fxRates?: FxRateIndex
|
||||
): MappedInvoice {
|
||||
const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value)
|
||||
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
|
||||
const vatAmount = round2(dto.taxTotal?.taxAmount.value ?? (total - subtotal))
|
||||
|
||||
// Determine primary VAT treatment from first line with tax
|
||||
const primaryTaxPercent = dto.lines.find(l => l.taxPercent != null)?.taxPercent
|
||||
const vatTreatment = inferVatTreatment(primaryTaxPercent, dto.currencyCode)
|
||||
const vat = resolveInvoiceVat(dto)
|
||||
const subtotal = vat.subtotal
|
||||
const vatAmount = vat.vatAmount
|
||||
|
||||
// Map Arcim status to Accounted status
|
||||
const statusMap: Record<string, string> = {
|
||||
@@ -535,8 +658,11 @@ export function mapSalesInvoice(
|
||||
vat_amount_sek: toSek(vatAmount, fx.sekFactor),
|
||||
total,
|
||||
total_sek: toSek(total, fx.sekFactor),
|
||||
vat_treatment: vatTreatment,
|
||||
vat_rate: inferVatRate(primaryTaxPercent),
|
||||
vat_treatment: vat.treatment,
|
||||
// null, not 25, when the payload established no rate. The column is
|
||||
// nullable and defaults to 25; writing the default explicitly is what made
|
||||
// 8 700+ migrated invoices assert "25 % moms" beside 0 kr of it.
|
||||
vat_rate: vat.rate,
|
||||
your_reference: null,
|
||||
our_reference: null,
|
||||
notes: dto.note || null,
|
||||
@@ -548,21 +674,43 @@ export function mapSalesInvoice(
|
||||
remaining_amount: dto.paymentStatus.paid ? 0 : Math.max(0, round2(dto.paymentStatus.balance.value)),
|
||||
}
|
||||
|
||||
const items = dto.lines.map((line, idx) => mapSalesInvoiceLine(line, idx))
|
||||
const items = dto.lines.map((line, idx) => mapSalesInvoiceLine(line, idx, vat.rate))
|
||||
|
||||
return { invoice, items, fxUnresolved: fx.unresolved }
|
||||
return { invoice, items, fxUnresolved: fx.unresolved, vatUnresolved: vat.unresolved }
|
||||
}
|
||||
|
||||
function mapSalesInvoiceLine(line: SalesInvoiceLineDto, index: number): Record<string, unknown> {
|
||||
/**
|
||||
* One invoice_items row.
|
||||
*
|
||||
* `invoiceRate` is the rate resolved for the invoice as a whole, used only
|
||||
* when the line itself states none. The booking engine sums `vat_amount`
|
||||
* across items to post 2611, so a line that carried a rate but no amount used
|
||||
* to contribute nothing: 3 451 of 4 030 migrated items at 25 % hold 0 kr.
|
||||
* Deriving the amount from whichever rate is known fixes that at the source.
|
||||
*
|
||||
* invoice_items.vat_rate is stored as a PERCENT (25), unlike
|
||||
* supplier_invoice_items.vat_rate which is a fraction (0.25).
|
||||
*/
|
||||
function mapSalesInvoiceLine(
|
||||
line: SalesInvoiceLineDto,
|
||||
index: number,
|
||||
invoiceRate: number | null,
|
||||
): Record<string, unknown> {
|
||||
const lineTotal = round2(line.lineExtensionAmount.value)
|
||||
const rate = line.taxPercent != null ? snapToSwedishRate(line.taxPercent) : invoiceRate
|
||||
const vatAmount = line.taxAmount?.value ?? lineVatFromPercent(lineTotal, rate ?? undefined)
|
||||
|
||||
return {
|
||||
sort_order: index + 1,
|
||||
description: line.description || line.itemName || '',
|
||||
quantity: line.quantity || 1,
|
||||
unit: line.unitCode || 'st',
|
||||
unit_price: round2(line.unitPrice?.value ?? line.lineExtensionAmount.value),
|
||||
line_total: round2(line.lineExtensionAmount.value),
|
||||
vat_rate: inferVatRate(line.taxPercent),
|
||||
vat_amount: round2(line.taxAmount?.value ?? 0),
|
||||
line_total: lineTotal,
|
||||
// 0 rather than the old hardcoded 25 when nothing established a rate: a
|
||||
// 0 % line beside 0 kr of VAT is at least internally consistent.
|
||||
vat_rate: rate ?? 0,
|
||||
vat_amount: round2(vatAmount ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,12 +721,11 @@ export function mapSupplierInvoice(
|
||||
supplierId: string,
|
||||
fxRates?: FxRateIndex
|
||||
): MappedInvoice {
|
||||
const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value)
|
||||
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
|
||||
const vatAmount = round2(dto.taxTotal?.taxAmount.value ?? (total - subtotal))
|
||||
|
||||
const primaryTaxPercent = dto.lines.find(l => l.taxPercent != null)?.taxPercent
|
||||
const vatTreatment = inferVatTreatment(primaryTaxPercent, dto.currencyCode)
|
||||
const vat = resolveInvoiceVat(dto)
|
||||
const subtotal = vat.subtotal
|
||||
const vatAmount = vat.vatAmount
|
||||
const vatTreatment = vat.treatment
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
draft: 'registered',
|
||||
@@ -664,25 +811,43 @@ export function mapSupplierInvoice(
|
||||
notes: dto.note || null,
|
||||
}
|
||||
|
||||
const items = dto.lines.map((line, idx) => mapSupplierInvoiceLine(line, idx))
|
||||
const items = dto.lines.map((line, idx) => mapSupplierInvoiceLine(line, idx, vat.rate))
|
||||
|
||||
return { invoice, items, fxUnresolved: fx.unresolved }
|
||||
return { invoice, items, fxUnresolved: fx.unresolved, vatUnresolved: vat.unresolved }
|
||||
}
|
||||
|
||||
function mapSupplierInvoiceLine(line: SupplierInvoiceLineDto, index: number): Record<string, unknown> {
|
||||
/**
|
||||
* One supplier_invoice_items row.
|
||||
*
|
||||
* `invoiceRate` (percent) is the rate resolved for the invoice as a whole and
|
||||
* is used only when the line states none: the previous `?? 25` asserted a
|
||||
* standard rate on every line of every provider that omits per-line VAT, and
|
||||
* paired it with a 0 kr amount.
|
||||
*/
|
||||
function mapSupplierInvoiceLine(
|
||||
line: SupplierInvoiceLineDto,
|
||||
index: number,
|
||||
invoiceRate: number | null,
|
||||
): Record<string, unknown> {
|
||||
const lineTotal = round2(line.lineExtensionAmount.value)
|
||||
// Foreign rates (19 % DE) must survive rather than be snapped to a Swedish
|
||||
// one, so the line's own percent is used as stated; only the fallback comes
|
||||
// from the invoice-level resolution.
|
||||
const percent = line.taxPercent ?? invoiceRate ?? 0
|
||||
const vatAmount = line.taxAmount?.value ?? lineVatFromPercent(lineTotal, percent)
|
||||
|
||||
return {
|
||||
sort_order: index + 1,
|
||||
description: line.description || line.itemName || '',
|
||||
quantity: line.quantity || 1,
|
||||
unit: line.unitCode || 'st',
|
||||
unit_price: round2(line.unitPrice?.value ?? line.lineExtensionAmount.value),
|
||||
line_total: round2(line.lineExtensionAmount.value),
|
||||
line_total: lineTotal,
|
||||
account_number: line.accountNumber || '4000', // Default to purchases
|
||||
// supplier_invoice_items stores decimal fractions (0.25 = 25 %), unlike
|
||||
// customer invoice_items which store percent; foreign rates (19 % DE)
|
||||
// must survive as 0.19 rather than be coerced to a Swedish rate.
|
||||
vat_rate: normalizeVatRateToFraction(line.taxPercent ?? 25),
|
||||
vat_amount: round2(line.taxAmount?.value ?? 0),
|
||||
// customer invoice_items which store percent.
|
||||
vat_rate: normalizeVatRateToFraction(percent),
|
||||
vat_amount: round2(vatAmount ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
fetchCompanyInfoDirect,
|
||||
fetchCustomersDirect,
|
||||
fetchSuppliersDirect,
|
||||
fetchSalesInvoicesDirect,
|
||||
fetchSupplierInvoicesDirect,
|
||||
fetchSalesInvoicesHydrated,
|
||||
fetchSupplierInvoicesHydrated,
|
||||
} from '@/lib/providers/provider-data-fetcher'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
@@ -488,7 +488,11 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
if (options.importSalesInvoices !== false) {
|
||||
emitProgress(options, { status: 'importing', currentStep: 'Importerar kundfakturor...', progress: 60 })
|
||||
try {
|
||||
const invoices = await fetchSalesInvoicesDirect(provider, accessToken, providerCompanyId)
|
||||
// Hydrated, not the bare list: the list payload omits VAT, the net
|
||||
// and the line items for most providers (see provider-data-fetcher).
|
||||
const { invoices, hydration } = await fetchSalesInvoicesHydrated(
|
||||
provider, accessToken, providerCompanyId,
|
||||
)
|
||||
console.log(`[migration] Sales invoices: ${invoices.length} total`)
|
||||
|
||||
// Bulk-load existing invoice numbers once.
|
||||
@@ -642,6 +646,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
ready.map((r) => ({ currencyCode: r.dto.currencyCode, issueDate: r.dto.issueDate }))
|
||||
)
|
||||
let fxUnresolved = 0
|
||||
let vatUnresolved = 0
|
||||
|
||||
// Phase C: chunk-insert invoices + their line items.
|
||||
for (const batch of chunk(ready, INSERT_CHUNK_SIZE)) {
|
||||
@@ -677,6 +682,13 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
fxUnresolved++
|
||||
logFxUnresolved('Sales invoice', mappedBatch[i].dto.invoiceNumber, fx)
|
||||
}
|
||||
if (mappedBatch[i].vatUnresolved) {
|
||||
vatUnresolved++
|
||||
console.warn(
|
||||
`[migration] Sales invoice ${mappedBatch[i].dto.invoiceNumber}: no VAT in provider payload; `
|
||||
+ 'imported with gross as subtotal and a null rate.'
|
||||
)
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
@@ -690,7 +702,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
}
|
||||
}
|
||||
|
||||
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, errorSample: errorSample ?? undefined }
|
||||
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, vatUnresolved, hydration, errorSample: errorSample ?? undefined }
|
||||
} catch (err) {
|
||||
console.error('Failed to import sales invoices:', err)
|
||||
recordStepError(results, 'salesInvoices', err)
|
||||
@@ -701,7 +713,9 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
if (options.importSupplierInvoices !== false) {
|
||||
emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörsfakturor...', progress: 80 })
|
||||
try {
|
||||
const invoices = await fetchSupplierInvoicesDirect(provider, accessToken, providerCompanyId)
|
||||
const { invoices, hydration } = await fetchSupplierInvoicesHydrated(
|
||||
provider, accessToken, providerCompanyId,
|
||||
)
|
||||
console.log(`[migration] Supplier invoices: ${invoices.length} total`)
|
||||
|
||||
// Load existing (supplier_invoice_number, supplier_id) pairs once.
|
||||
@@ -863,14 +877,15 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
ready.map((r) => ({ currencyCode: r.dto.currencyCode, issueDate: r.dto.issueDate }))
|
||||
)
|
||||
let fxUnresolved = 0
|
||||
let vatUnresolved = 0
|
||||
|
||||
for (const batch of chunk(ready, INSERT_CHUNK_SIZE)) {
|
||||
const mappedBatch = batch.map((r) => {
|
||||
const { invoice, items, fxUnresolved: fx } = mapSupplierInvoice(
|
||||
const { invoice, items, fxUnresolved: fx, vatUnresolved: vatMissing } = mapSupplierInvoice(
|
||||
r.dto, userId, companyId, r.supplierId, fxRates
|
||||
)
|
||||
invoice.arrival_number = nextArrivalNumber++
|
||||
return { invoice, items, fxUnresolved: fx, dto: r.dto }
|
||||
return { invoice, items, fxUnresolved: fx, vatUnresolved: vatMissing, dto: r.dto }
|
||||
})
|
||||
|
||||
const outcome = await insertWithPerRowFallback(
|
||||
@@ -904,6 +919,13 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
fxUnresolved++
|
||||
logFxUnresolved('Supplier invoice', mappedBatch[i].dto.invoiceNumber, fx)
|
||||
}
|
||||
if (mappedBatch[i].vatUnresolved) {
|
||||
vatUnresolved++
|
||||
console.warn(
|
||||
`[migration] Supplier invoice ${mappedBatch[i].dto.invoiceNumber}: no VAT in provider payload; `
|
||||
+ 'imported with gross as subtotal.'
|
||||
)
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
@@ -917,7 +939,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
}
|
||||
}
|
||||
|
||||
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, errorSample: errorSample ?? undefined }
|
||||
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, vatUnresolved, hydration, errorSample: errorSample ?? undefined }
|
||||
} catch (err) {
|
||||
console.error('Failed to import supplier invoices:', err)
|
||||
recordStepError(results, 'supplierInvoices', err)
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* instead of being duplicated here.
|
||||
*/
|
||||
|
||||
import type { HydrationReport } from '@/lib/providers/provider-data-fetcher'
|
||||
|
||||
// Re-export canonical DTOs used by entity-mapper and migration-orchestrator
|
||||
export type {
|
||||
AmountType,
|
||||
@@ -89,12 +91,32 @@ export interface MigrationStepError {
|
||||
* set, and the migration reports them here instead of passing them off as
|
||||
* ordinary imports. Per-invoice detail goes to the server log.
|
||||
*/
|
||||
/**
|
||||
* Per-step outcome for the two invoice registers.
|
||||
*
|
||||
* `vatUnresolved` counts invoices whose provider payload established no VAT at
|
||||
* all; they are imported with the gross as subtotal and a null rate, which is
|
||||
* the only honest reading of "the source did not say". `hydration` reports how
|
||||
* many detail payloads were fetched, and how many the time budget could not
|
||||
* reach, so a partially hydrated run is visible rather than looking complete.
|
||||
*/
|
||||
export interface InvoiceStepResult {
|
||||
total: number
|
||||
imported: number
|
||||
skipped: number
|
||||
skipReasons?: SkipReasons
|
||||
fxUnresolved?: number
|
||||
vatUnresolved?: number
|
||||
hydration?: HydrationReport
|
||||
errorSample?: string
|
||||
}
|
||||
|
||||
export interface MigrationResults {
|
||||
companyInfo?: { imported: boolean }
|
||||
customers?: { total: number; imported: number; updated?: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
|
||||
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
|
||||
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; fxUnresolved?: number; errorSample?: string }
|
||||
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; fxUnresolved?: number; errorSample?: string }
|
||||
salesInvoices?: InvoiceStepResult
|
||||
supplierInvoices?: InvoiceStepResult
|
||||
/**
|
||||
* Auto-reconciliation of imported supplier invoices to the GL payment
|
||||
* vouchers that the separate SIE import already posted. `autoLinked` invoices
|
||||
|
||||
Reference in New Issue
Block a user