* 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>
885 lines
34 KiB
TypeScript
885 lines
34 KiB
TypeScript
/**
|
|
* Maps Arcim Sync canonical DTOs to Accounted internal types.
|
|
*
|
|
* These mappers transform the normalized data from any Swedish accounting
|
|
* provider into the exact shapes Accounted expects for database insertion.
|
|
*/
|
|
|
|
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,
|
|
SupplierDto,
|
|
SalesInvoiceDto,
|
|
SalesInvoiceLineDto,
|
|
SupplierInvoiceDto,
|
|
SupplierInvoiceLineDto,
|
|
CompanyInformationDto,
|
|
PostalAddress,
|
|
PartyDto,
|
|
} from '@/lib/providers/dto'
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
function round2(n: number): number {
|
|
return Math.round(n * 100) / 100
|
|
}
|
|
|
|
function formatAddress(addr?: PostalAddress): {
|
|
address_line1: string | null
|
|
address_line2: string | null
|
|
postal_code: string | null
|
|
city: string | null
|
|
country: string | null
|
|
} {
|
|
if (!addr) {
|
|
return { address_line1: null, address_line2: null, postal_code: null, city: null, country: null }
|
|
}
|
|
const line1 = [addr.streetName, addr.buildingNumber].filter(Boolean).join(' ') || null
|
|
return {
|
|
address_line1: line1,
|
|
address_line2: addr.additionalStreetName || null,
|
|
postal_code: addr.postalZone || null,
|
|
city: addr.cityName || null,
|
|
country: addr.countryCode || null,
|
|
}
|
|
}
|
|
|
|
function getOrgNumber(party: PartyDto): string | null {
|
|
// Look for SE:ORGNR scheme first, then companyId in legalEntity
|
|
const seOrg = party.identifications?.find(i => i.schemeId === 'SE:ORGNR')
|
|
if (seOrg) return seOrg.id
|
|
return party.legalEntity?.companyId || null
|
|
}
|
|
|
|
const EU_COUNTRIES = ['AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES', 'FI', 'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SI', 'SK']
|
|
|
|
/**
|
|
* Check if a string looks like a Swedish org number (XXXXXX-XXXX or 10 digits).
|
|
* Swedish org numbers are 10 digits where the third digit is >= 2 (to distinguish
|
|
* from personal numbers where month 01-12 appears in positions 3-4).
|
|
*/
|
|
function looksLikeSwedishOrgNumber(orgNumber: string | null | undefined): boolean {
|
|
if (!orgNumber) return false
|
|
const digits = orgNumber.replace(/[-\s]/g, '')
|
|
if (digits.length !== 10 || !/^\d+$/.test(digits)) return false
|
|
// Third digit >= 2 distinguishes org numbers from personal numbers
|
|
const thirdDigit = parseInt(digits[2], 10)
|
|
return thirdDigit >= 2
|
|
}
|
|
|
|
/**
|
|
* Check if a string looks like a Swedish identity number: an organisation
|
|
* number or personnummer in 10-digit form, or a personnummer in the 12-digit
|
|
* century-prefixed form (19xx / 20xx). Used to avoid misclassifying a domestic
|
|
* party as foreign just because its number isn't exactly 10 digits: a 12-digit
|
|
* personnummer like 19700616-7113 is Swedish, not an unknown foreign org number.
|
|
*/
|
|
function looksLikeSwedishIdNumber(orgNumber: string | null | undefined): boolean {
|
|
if (!orgNumber) return false
|
|
const digits = orgNumber.replace(/[-+\s]/g, '')
|
|
if (!/^\d+$/.test(digits)) return false
|
|
if (digits.length === 10) return true
|
|
return digits.length === 12 && /^(19|20)/.test(digits)
|
|
}
|
|
|
|
/**
|
|
* Company name suffixes that indicate a foreign (non-Swedish) entity.
|
|
* These override the default swedish_business assumption when no other
|
|
* signals (VAT, country code, org number) are available.
|
|
*/
|
|
const FOREIGN_SUFFIXES: { suffix: string; region: 'eu' | 'non_eu' }[] = [
|
|
// German
|
|
{ suffix: 'gmbh', region: 'eu' },
|
|
{ suffix: 'ag', region: 'eu' },
|
|
{ suffix: 'e.v.', region: 'eu' },
|
|
{ suffix: 'ohg', region: 'eu' },
|
|
{ suffix: 'kg', region: 'eu' },
|
|
{ suffix: 'ug', region: 'eu' },
|
|
// French
|
|
{ suffix: 'sarl', region: 'eu' },
|
|
{ suffix: 's.a.r.l.', region: 'eu' },
|
|
{ suffix: 'sas', region: 'eu' },
|
|
// Dutch/Belgian
|
|
{ suffix: 'b.v.', region: 'eu' },
|
|
{ suffix: 'n.v.', region: 'eu' },
|
|
{ suffix: 'bv', region: 'eu' },
|
|
{ suffix: 'nv', region: 'eu' },
|
|
// Spanish/Italian
|
|
{ suffix: 's.l.', region: 'eu' },
|
|
{ suffix: 's.r.l.', region: 'eu' },
|
|
// Finnish
|
|
{ suffix: 'oy', region: 'eu' },
|
|
{ suffix: 'oyj', region: 'eu' },
|
|
// Danish/Norwegian
|
|
{ suffix: 'a/s', region: 'eu' },
|
|
{ suffix: 'aps', region: 'eu' },
|
|
// Anglo (could be UK, US, etc.: treat as non-EU since UK left)
|
|
{ suffix: 'ltd', region: 'non_eu' },
|
|
{ suffix: 'limited', region: 'non_eu' },
|
|
{ suffix: 'llc', region: 'non_eu' },
|
|
{ suffix: 'inc', region: 'non_eu' },
|
|
{ suffix: 'corp', region: 'non_eu' },
|
|
{ suffix: 'plc', region: 'non_eu' },
|
|
// Irish (EU)
|
|
{ suffix: 'dac', region: 'eu' },
|
|
]
|
|
|
|
function inferRegionFromName(name: string | undefined): 'eu' | 'non_eu' | null {
|
|
if (!name) return null
|
|
const lower = name.toLowerCase().trim()
|
|
for (const { suffix, region } of FOREIGN_SUFFIXES) {
|
|
// Match as a word boundary at the end: "Acme GmbH" but not "Gmbhsson"
|
|
if (lower.endsWith(suffix) || lower.endsWith(suffix + '.')) {
|
|
// Check that there's a space or start before the suffix
|
|
const pos = lower.lastIndexOf(suffix)
|
|
if (pos === 0 || lower[pos - 1] === ' ') {
|
|
return region
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function inferTypeFromVatOrCountry(
|
|
vatNumber: string | undefined,
|
|
countryCode: string | undefined,
|
|
orgNumber?: string | null,
|
|
companyName?: string
|
|
): 'swedish_business' | 'eu_business' | 'non_eu_business' {
|
|
// 1. VAT number prefix is the strongest signal
|
|
if (vatNumber) {
|
|
const prefix = vatNumber.substring(0, 2).toUpperCase()
|
|
if (prefix === 'SE') return 'swedish_business'
|
|
if (EU_COUNTRIES.includes(prefix)) return 'eu_business'
|
|
return 'non_eu_business'
|
|
}
|
|
|
|
// 2. Explicit country code
|
|
const country = countryCode?.toUpperCase()
|
|
if (country === 'SE') return 'swedish_business'
|
|
if (country && EU_COUNTRIES.includes(country)) return 'eu_business'
|
|
if (country) return 'non_eu_business'
|
|
|
|
// 3. Swedish-format org number is strong evidence of domestic entity
|
|
if (looksLikeSwedishOrgNumber(orgNumber)) return 'swedish_business'
|
|
|
|
// 4. A number that isn't a Swedish-format identity number → foreign entity.
|
|
// Accepts both 10-digit and 12-digit (century-prefixed) Swedish numbers so
|
|
// a domestic personnummer like 19700616-7113 isn't treated as foreign.
|
|
if (orgNumber) {
|
|
const digits = orgNumber.replace(/[-+\s]/g, '')
|
|
if (digits.length > 0 && !looksLikeSwedishIdNumber(orgNumber)) {
|
|
// Not a Swedish number: use name heuristic or default to non_eu
|
|
const nameRegion = inferRegionFromName(companyName)
|
|
if (nameRegion === 'eu') return 'eu_business'
|
|
return 'non_eu_business'
|
|
}
|
|
}
|
|
|
|
// 5. Company name suffix heuristic (GmbH, Ltd, etc.)
|
|
const nameRegion = inferRegionFromName(companyName)
|
|
if (nameRegion === 'eu') return 'eu_business'
|
|
if (nameRegion === 'non_eu') return 'non_eu_business'
|
|
|
|
// 6. No signal at all: default to swedish_business (most common in Swedish systems)
|
|
return 'swedish_business'
|
|
}
|
|
|
|
function inferCustomerType(dto: CustomerDto): CustomerType {
|
|
if (dto.type === 'private') return 'individual'
|
|
return inferTypeFromVatOrCountry(
|
|
dto.vatNumber,
|
|
dto.party.postalAddress?.countryCode,
|
|
getOrgNumber(dto.party),
|
|
dto.party.name
|
|
)
|
|
}
|
|
|
|
function inferSupplierType(dto: SupplierDto): SupplierType {
|
|
return inferTypeFromVatOrCountry(
|
|
dto.vatNumber,
|
|
dto.party.postalAddress?.countryCode,
|
|
getOrgNumber(dto.party),
|
|
dto.party.name
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Infer customer/supplier type from a PartyDto (used by orchestrator for
|
|
* minimal entity creation from invoice data).
|
|
*/
|
|
export function inferTypeFromParty(
|
|
party: PartyDto,
|
|
vatNumber?: string
|
|
): 'swedish_business' | 'eu_business' | 'non_eu_business' {
|
|
return inferTypeFromVatOrCountry(
|
|
vatNumber,
|
|
party.postalAddress?.countryCode,
|
|
getOrgNumber(party),
|
|
party.name
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
/** 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 ─────────────────────────────────────────────
|
|
//
|
|
// The provider DTOs (lib/providers/dto.ts) carry NO exchange rate and NO SEK
|
|
// amount: an invoice exposes only `currencyCode` plus amounts already expressed
|
|
// in that currency. So for a foreign-currency document the SEK value has to be
|
|
// established here, at import, from the rate that was valid on the document's
|
|
// OWN date. An imported invoice is räkenskapsinformation (BFL 7 kap): its SEK
|
|
// value is part of the record, and stamping it with today's rate, or with a
|
|
// fabricated 1:1, would misstate it.
|
|
//
|
|
// Same pattern as lib/transactions/ingest.ts: pre-resolve the unique
|
|
// (currency, date) pairs once through fetchExchangeRate WITH the supabase
|
|
// client (so the shared `exchange_rates` cache absorbs repeat dates) and WITH
|
|
// the document date, then map synchronously against that index.
|
|
|
|
/**
|
|
* Currencies Riksbanken publishes a series for (SERIES_IDS in
|
|
* lib/currency/riksbanken.ts). A document in any other currency has no rate
|
|
* source at all, so it is reported rather than written with a silent null.
|
|
*/
|
|
const CONVERTIBLE_CURRENCIES: readonly Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
|
|
|
/** Riksbanken fan-out bound, mirroring ingest.ts: a wide historical backfill
|
|
* used to fire every pair at once and get the whole batch rate-limited. */
|
|
const FX_FETCH_CONCURRENCY = 4
|
|
|
|
function asConvertibleCurrency(code: string | undefined | null): Currency | null {
|
|
if (!code) return null
|
|
const upper = code.toUpperCase() as Currency
|
|
return CONVERTIBLE_CURRENCIES.includes(upper) ? upper : null
|
|
}
|
|
|
|
/** Normalize a DTO date to a plain ISO day, or null when it isn't one. */
|
|
function isoDay(value: string | undefined | null): string | null {
|
|
if (!value || !/^\d{4}-\d{2}-\d{2}/.test(value)) return null
|
|
return value.slice(0, 10)
|
|
}
|
|
|
|
/** Pre-resolved rates, keyed by `${CURRENCY}|${YYYY-MM-DD}`. */
|
|
export type FxRateIndex = Map<string, ExchangeRate>
|
|
|
|
export function fxRateKey(currencyCode: string, isoDate: string): string {
|
|
return `${currencyCode.toUpperCase()}|${isoDate}`
|
|
}
|
|
|
|
/** Why a foreign-currency document could not be converted to SEK. */
|
|
export type FxUnresolvedReason =
|
|
/** Currency outside Riksbanken's published series: no rate source exists. */
|
|
| 'unsupported_currency'
|
|
/** Rate source exists but no observation could be obtained for that date. */
|
|
| 'rate_unavailable'
|
|
|
|
export interface FxUnresolved {
|
|
currency: string
|
|
/** The document's own date, i.e. the date a rate was needed for. */
|
|
date: string
|
|
reason: FxUnresolvedReason
|
|
}
|
|
|
|
interface FxResolution {
|
|
/** null for SEK documents (no rate applies) and for unconvertible ones. */
|
|
rate: number | null
|
|
/** Riksbanken observation date behind `rate`; null when there is no rate. */
|
|
rateDate: string | null
|
|
/** Factor to reach SEK: 1 for SEK documents, `rate` otherwise, null when
|
|
* the conversion could not be established at all. */
|
|
sekFactor: number | null
|
|
/** Set ONLY when a foreign document could not be converted. */
|
|
unresolved: FxUnresolved | null
|
|
}
|
|
|
|
/**
|
|
* Fetch the rate valid on each document's own date, once per unique
|
|
* (currency, date) pair. SEK documents need no rate and are skipped.
|
|
*
|
|
* A pair that cannot be fetched is simply absent from the index; the mapper
|
|
* then reports that document as unresolved rather than inventing a number.
|
|
*/
|
|
export async function buildFxRateIndex(
|
|
supabase: SupabaseClient,
|
|
documents: { currencyCode?: string; issueDate?: string }[],
|
|
): Promise<FxRateIndex> {
|
|
const index: FxRateIndex = new Map()
|
|
|
|
const pairs = new Map<string, { currency: Currency; date: string }>()
|
|
for (const doc of documents) {
|
|
const currency = asConvertibleCurrency(doc.currencyCode)
|
|
if (!currency || currency === 'SEK') continue
|
|
const date = isoDay(doc.issueDate)
|
|
if (!date) continue
|
|
const key = fxRateKey(currency, date)
|
|
if (!pairs.has(key)) pairs.set(key, { currency, date })
|
|
}
|
|
if (pairs.size === 0) return index
|
|
|
|
const entries = [...pairs.entries()]
|
|
for (let i = 0; i < entries.length; i += FX_FETCH_CONCURRENCY) {
|
|
const slice = entries.slice(i, i + FX_FETCH_CONCURRENCY)
|
|
const settled = await Promise.allSettled(
|
|
slice.map(([, { currency, date }]) => fetchExchangeRate(currency, new Date(date), supabase)),
|
|
)
|
|
for (let j = 0; j < slice.length; j++) {
|
|
const outcome = settled[j]
|
|
if (outcome.status === 'fulfilled' && outcome.value && outcome.value.rate > 0) {
|
|
index.set(slice[j][0], outcome.value)
|
|
}
|
|
// A miss deliberately leaves the key unset: never a made-up rate.
|
|
}
|
|
}
|
|
|
|
return index
|
|
}
|
|
|
|
/**
|
|
* Resolve the SEK conversion for one document.
|
|
*
|
|
* `rates` is optional so existing callers keep compiling; when it is omitted a
|
|
* foreign document resolves to `rate_unavailable` (reported), never to a
|
|
* silent 1:1. Only an actually-fetched positive rate produces a conversion.
|
|
*/
|
|
function resolveFx(
|
|
currencyCode: string | undefined,
|
|
issueDate: string | undefined,
|
|
rates?: FxRateIndex,
|
|
): FxResolution {
|
|
const code = (currencyCode || 'SEK').toUpperCase()
|
|
|
|
if (code === 'SEK') {
|
|
// Domestic document: the ledger currency IS SEK, so there is no exchange
|
|
// rate to record. A plain null, not a conversion we failed to make. The
|
|
// SEK amount columns still get filled, via sekFactor 1.
|
|
return { rate: null, rateDate: null, sekFactor: 1, unresolved: null }
|
|
}
|
|
|
|
const currency = asConvertibleCurrency(code)
|
|
if (!currency) {
|
|
return {
|
|
rate: null, rateDate: null, sekFactor: null,
|
|
unresolved: { currency: code, date: isoDay(issueDate) ?? '', reason: 'unsupported_currency' },
|
|
}
|
|
}
|
|
|
|
const date = isoDay(issueDate)
|
|
if (!date) {
|
|
return {
|
|
rate: null, rateDate: null, sekFactor: null,
|
|
unresolved: { currency: code, date: issueDate ?? '', reason: 'rate_unavailable' },
|
|
}
|
|
}
|
|
|
|
const hit = rates?.get(fxRateKey(currency, date))
|
|
if (!hit || !(hit.rate > 0)) {
|
|
return {
|
|
rate: null, rateDate: null, sekFactor: null,
|
|
unresolved: { currency: code, date, reason: 'rate_unavailable' },
|
|
}
|
|
}
|
|
|
|
return { rate: hit.rate, rateDate: hit.date, sekFactor: hit.rate, unresolved: null }
|
|
}
|
|
|
|
/** Convert to SEK, or null when no conversion could be established. */
|
|
function toSek(amount: number, sekFactor: number | null): number | null {
|
|
return sekFactor === null ? null : round2(amount * sekFactor)
|
|
}
|
|
|
|
/**
|
|
* A mapped invoice plus the FX verdict for it. `fxUnresolved` is non-null only
|
|
* for a FOREIGN document whose SEK value could not be established: the caller
|
|
* must surface those as needing attention instead of letting them pass as
|
|
* ordinary imports. They are still imported (dropping them would lose
|
|
* räkenskapsinformation) but carry exchange_rate = null, so every booking path
|
|
* refuses them loudly (SupplierInvoiceFxRateMissingError /
|
|
* InvoiceBookingRateMissingError) rather than posting at a fabricated 1:1.
|
|
*/
|
|
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 ──────────────────────────────────────────────────
|
|
|
|
export function mapCustomer(dto: CustomerDto, userId: string, companyId: string): Record<string, unknown> {
|
|
const addr = formatAddress(dto.party.postalAddress)
|
|
const customerType = inferCustomerType(dto)
|
|
const number = getOrgNumber(dto.party)
|
|
// The provider exposes a single identity-number field, but Accounted stores a
|
|
// personnummer in `personal_number` (individuals) and an org number in
|
|
// `org_number` (businesses). Route it to the column the type expects: else a
|
|
// Privatperson's personnummer lands in org_number and is hidden by the
|
|
// individual customer form, which renders personal_number for individuals.
|
|
const isIndividual = customerType === 'individual'
|
|
// personal_number is an encrypted column: customers_personal_number_check
|
|
// (migration 20260726110000) accepts AES-256-GCM hex and nothing else, so
|
|
// writing the identity number in plaintext here aborts the whole import with
|
|
// 23514 the moment a Privatperson appears in the source data.
|
|
return {
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
name: dto.party.name,
|
|
customer_type: customerType,
|
|
contact_person: dto.party.contact?.name || null,
|
|
email: dto.party.contact?.email || null,
|
|
phone: dto.party.contact?.telephone || null,
|
|
invoice_email_cc_addresses: dto.invoiceEmailCcAddresses ?? null,
|
|
invoice_email_bcc_addresses: dto.invoiceEmailBccAddresses ?? null,
|
|
...addr,
|
|
org_number: isIndividual ? null : number,
|
|
personal_number: isIndividual ? encryptCustomerPersonalNumber(number) : null,
|
|
vat_number: dto.vatNumber || null,
|
|
vat_number_validated: false,
|
|
default_payment_terms: dto.defaultPaymentTermsDays || 30,
|
|
notes: dto.note || null,
|
|
}
|
|
}
|
|
|
|
export function mapSupplier(dto: SupplierDto, userId: string, companyId: string): Record<string, unknown> {
|
|
const addr = formatAddress(dto.party.postalAddress)
|
|
return {
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
name: dto.party.name,
|
|
supplier_type: inferSupplierType(dto),
|
|
email: dto.party.contact?.email || null,
|
|
phone: dto.party.contact?.telephone || null,
|
|
...addr,
|
|
org_number: getOrgNumber(dto.party),
|
|
vat_number: dto.vatNumber || null,
|
|
bankgiro: dto.bankGiro || null,
|
|
plusgiro: dto.plusGiro || null,
|
|
bank_account: dto.bankAccount || null,
|
|
// SupplierDto carries bankAccount/bankGiro/plusGiro only: it has no IBAN,
|
|
// BIC or default expense account, so these are honest nulls, not dropped
|
|
// data. The user fills them in when a foreign payment first needs them.
|
|
iban: null,
|
|
bic: null,
|
|
default_expense_account: null,
|
|
default_payment_terms: dto.defaultPaymentTermsDays || 30,
|
|
default_currency: 'SEK',
|
|
notes: dto.note || null,
|
|
}
|
|
}
|
|
|
|
export function mapSalesInvoice(
|
|
dto: SalesInvoiceDto,
|
|
userId: string,
|
|
companyId: string,
|
|
customerId: string,
|
|
fxRates?: FxRateIndex
|
|
): MappedInvoice {
|
|
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
|
|
const vat = resolveInvoiceVat(dto)
|
|
const subtotal = vat.subtotal
|
|
const vatAmount = vat.vatAmount
|
|
|
|
// Map Arcim status to Accounted status
|
|
const statusMap: Record<string, string> = {
|
|
draft: 'draft',
|
|
sent: 'sent',
|
|
booked: 'sent', // Accounted has no 'booked' status: treat as sent
|
|
paid: 'paid',
|
|
overdue: 'overdue',
|
|
cancelled: 'cancelled',
|
|
credited: 'credited',
|
|
}
|
|
|
|
const isCreditNote = dto.invoiceTypeCode === '381'
|
|
|
|
// SEK value of a foreign invoice, at the rate valid on its own issue date.
|
|
const fx = resolveFx(dto.currencyCode, dto.issueDate, fxRates)
|
|
|
|
const invoice: Record<string, unknown> = {
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
customer_id: customerId,
|
|
// Empty string must become NULL: the UNIQUE (company_id, invoice_number)
|
|
// index is partial on NOT NULL, so '' from a provider payload missing the
|
|
// field would collide on the second invoice and reject the insert.
|
|
invoice_number: dto.invoiceNumber || null,
|
|
invoice_date: dto.issueDate,
|
|
due_date: dto.dueDate || dto.issueDate,
|
|
status: statusMap[dto.status] || 'sent',
|
|
currency: dto.currencyCode || 'SEK',
|
|
// null for a SEK invoice (no rate applies) and for a foreign invoice whose
|
|
// rate could not be established: that case is reported via fxUnresolved.
|
|
exchange_rate: fx.rate,
|
|
exchange_rate_date: fx.rateDate,
|
|
subtotal,
|
|
subtotal_sek: toSek(subtotal, fx.sekFactor),
|
|
vat_amount: vatAmount,
|
|
vat_amount_sek: toSek(vatAmount, fx.sekFactor),
|
|
total,
|
|
total_sek: toSek(total, fx.sekFactor),
|
|
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,
|
|
document_type: isCreditNote ? 'credit_note' : 'invoice',
|
|
paid_at: dto.paymentStatus.paid ? dto.paymentStatus.lastPaymentDate || dto.issueDate : null,
|
|
paid_amount: dto.paymentStatus.paid ? total : round2(total - dto.paymentStatus.balance.value),
|
|
// remaining_amount is NOT NULL DEFAULT 0, so omitting it makes every
|
|
// migrated open invoice look fully settled in AR aging.
|
|
remaining_amount: dto.paymentStatus.paid ? 0 : Math.max(0, round2(dto.paymentStatus.balance.value)),
|
|
}
|
|
|
|
const items = dto.lines.map((line, idx) => mapSalesInvoiceLine(line, idx, vat.rate))
|
|
|
|
return { invoice, items, fxUnresolved: fx.unresolved, vatUnresolved: vat.unresolved }
|
|
}
|
|
|
|
/**
|
|
* 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: 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),
|
|
}
|
|
}
|
|
|
|
export function mapSupplierInvoice(
|
|
dto: SupplierInvoiceDto,
|
|
userId: string,
|
|
companyId: string,
|
|
supplierId: string,
|
|
fxRates?: FxRateIndex
|
|
): MappedInvoice {
|
|
const total = round2(dto.legalMonetaryTotal.payableAmount.value)
|
|
const vat = resolveInvoiceVat(dto)
|
|
const subtotal = vat.subtotal
|
|
const vatAmount = vat.vatAmount
|
|
const vatTreatment = vat.treatment
|
|
|
|
const statusMap: Record<string, string> = {
|
|
draft: 'registered',
|
|
sent: 'registered',
|
|
booked: 'registered',
|
|
paid: 'paid',
|
|
overdue: 'overdue',
|
|
cancelled: 'credited',
|
|
credited: 'credited',
|
|
}
|
|
|
|
const isCreditNote = dto.invoiceTypeCode === '381'
|
|
|
|
// Payment-derived amounts. Treat Balance numerically (never strict === 0) so
|
|
// floating drift or a residual öre resolves cleanly to paid/unpaid.
|
|
const balance = round2(dto.paymentStatus.balance.value)
|
|
const paidAmount = dto.paymentStatus.paid ? total : round2(total - balance)
|
|
|
|
// Status MUST stay consistent with the payment amounts. The provider's
|
|
// lifecycle status (dto.status) and its payment status are computed
|
|
// independently upstream and can contradict each other (e.g. a Fortnox
|
|
// invoice that is "booked" but fully paid). Payment state wins:
|
|
// fully paid -> 'paid'
|
|
// 0 < paid < total -> 'partially_paid'
|
|
// otherwise -> the mapped lifecycle status
|
|
const mappedStatus = statusMap[dto.status] || 'registered'
|
|
let resolvedStatus: string
|
|
if (isCreditNote) {
|
|
// A kreditfaktura is never an open or "paid" payable. Force a credit-note
|
|
// terminal status regardless of the provider's lifecycle status: the
|
|
// arcim gateway is the only source of invoiceTypeCode and is NOT guaranteed
|
|
// to also send status='credited', so trusting dto.status here could persist
|
|
// a credit note as 'registered'/'paid' (contradicting its amounts).
|
|
resolvedStatus = mappedStatus === 'reversed' ? 'reversed' : 'credited'
|
|
} else if (mappedStatus === 'credited' || mappedStatus === 'reversed') {
|
|
// Terminal states from the provider: never flipped by payment.
|
|
resolvedStatus = mappedStatus
|
|
} else if (dto.paymentStatus.paid || balance <= 0) {
|
|
resolvedStatus = 'paid'
|
|
} else if (paidAmount > 0 && paidAmount < total) {
|
|
resolvedStatus = 'partially_paid'
|
|
} else {
|
|
resolvedStatus = mappedStatus
|
|
}
|
|
|
|
// SEK value of a foreign invoice, at the rate valid on its own issue date.
|
|
const fx = resolveFx(dto.currencyCode, dto.issueDate, fxRates)
|
|
|
|
const invoice: Record<string, unknown> = {
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
supplier_id: supplierId,
|
|
// Empty string must become NULL: with '' every number-less invoice from
|
|
// the same supplier collides on the UNIQUE
|
|
// (company_id, supplier_id, supplier_invoice_number) index, while NULLs
|
|
// are treated as distinct.
|
|
supplier_invoice_number: dto.invoiceNumber || null,
|
|
invoice_date: dto.issueDate,
|
|
due_date: dto.dueDate || dto.issueDate,
|
|
received_date: dto.issueDate,
|
|
delivery_date: dto.deliveryDate || null,
|
|
status: resolvedStatus,
|
|
currency: dto.currencyCode || 'SEK',
|
|
// null for a SEK invoice (no rate applies) and for a foreign invoice whose
|
|
// rate could not be established: that case is reported via fxUnresolved.
|
|
exchange_rate: fx.rate,
|
|
exchange_rate_date: fx.rateDate,
|
|
subtotal,
|
|
subtotal_sek: toSek(subtotal, fx.sekFactor),
|
|
vat_amount: vatAmount,
|
|
vat_amount_sek: toSek(vatAmount, fx.sekFactor),
|
|
total,
|
|
total_sek: toSek(total, fx.sekFactor),
|
|
vat_treatment: vatTreatment,
|
|
reverse_charge: vatTreatment === 'reverse_charge',
|
|
payment_reference: dto.ocrNumber || null,
|
|
paid_at: resolvedStatus === 'paid' || resolvedStatus === 'partially_paid'
|
|
? dto.paymentStatus.lastPaymentDate || dto.issueDate
|
|
: null,
|
|
paid_amount: resolvedStatus === 'paid' ? total : Math.max(0, paidAmount),
|
|
remaining_amount: resolvedStatus === 'paid' ? 0 : Math.max(0, balance),
|
|
is_credit_note: isCreditNote,
|
|
notes: dto.note || null,
|
|
}
|
|
|
|
const items = dto.lines.map((line, idx) => mapSupplierInvoiceLine(line, idx, vat.rate))
|
|
|
|
return { invoice, items, fxUnresolved: fx.unresolved, vatUnresolved: vat.unresolved }
|
|
}
|
|
|
|
/**
|
|
* 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: 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.
|
|
vat_rate: normalizeVatRateToFraction(percent),
|
|
vat_amount: round2(vatAmount ?? 0),
|
|
}
|
|
}
|
|
|
|
export function mapCompanyInfo(dto: CompanyInformationDto): {
|
|
company_name: string | null
|
|
org_number: string | null
|
|
vat_number: string | null
|
|
fiscal_year_start_month: number
|
|
address_line1: string | null
|
|
postal_code: string | null
|
|
city: string | null
|
|
phone: string | null
|
|
email: string | null
|
|
} {
|
|
const addr = formatAddress(dto.address)
|
|
// Parse fiscal year start month from "MM-DD" format
|
|
let fiscalYearStartMonth = 1
|
|
if (dto.fiscalYearStart) {
|
|
const month = parseInt(dto.fiscalYearStart.split('-')[0], 10)
|
|
if (month >= 1 && month <= 12) fiscalYearStartMonth = month
|
|
}
|
|
|
|
return {
|
|
company_name: dto.companyName || null,
|
|
org_number: dto.organizationNumber || null,
|
|
vat_number: dto.vatNumber || null,
|
|
fiscal_year_start_month: fiscalYearStartMonth,
|
|
address_line1: addr.address_line1,
|
|
postal_code: addr.postal_code,
|
|
city: addr.city,
|
|
phone: dto.contact?.telephone || null,
|
|
email: dto.contact?.email || null,
|
|
}
|
|
}
|