Files
accounted/extensions/general/arcim-migration/lib/entity-mapper.ts
T
Jakob WennbergandClaude Opus 5 f1d76deaba fix(providers): stop dead-ending on a resource 403, and stop dropping every migrated kreditfaktura (#2113)
* fix(providers): stop dead-ending on a resource 403, and stop dropping every migrated kreditfaktura

Two independent defects in the provider migration, both customer-visible.

A per-resource 403 was classified as a dead grant. classifyProviderError mapped
any 401 or 403 to PROVIDER_AUTH_EXPIRED, which is fatal, so a Fortnox account
without leverantorsregister permission aborted the whole migration at the
suppliers step with "Anslutningen har gatt ut. Ateranslut" even though the same
token had just succeeded on the previous step. Reconnecting can never fix that,
and steps 4 and later never ran. The provider's own reason ("Saknar behorighet
for leverantorsregister.") never reached the user. A 403 is now non-fatal once
the same token has already succeeded in the run, the migration continues, and
the provider's reason is surfaced. A 401, or a 403 on the first call, keeps the
auth-expired path.

fetchCompanyInfoDirect swallowed every error and returned null, which made the
existing PROVIDER_API_MODULE_INACTIVE remediation unreachable: a Visma customer
whose api_standard module is off got a silent 200 with an empty company card
instead of the precise Swedish explanation that was already written.

Kreditfakturor were dropped entirely. entity-mapper wrote document_type
'credit_note', but invoices_document_type_check allows only invoice, proforma
and delivery_note, and credit notes are modelled by credited_invoice_id. Every
migrated kreditfaktura was rejected and counted as skipped. One customer
imported 255 sales invoices and 0 credit notes on 2026-08-31; AR and revenue
are overstated by the credited amounts, and kreditfakturor are
rakenskapsinformation. They now import as invoice rows with reversed amounts
and status 'credited', following the in-app credit convention. They import
unlinked: no provider DTO carries a reference to the invoice being credited, so
there is nothing to match on and guessing would corrupt the AR ledger. The
wizard says so instead of burying them in skipped.

Also makes the OAuth callback non-replayable from browser history (no-store
plus history replacement), which is what the "state rejected" events were: a
replay of a callback that had already succeeded seconds earlier. No
already-connected page, so consumed-vs-unknown state stays unobservable to an
unauthenticated caller. Expected PSD2 session expiry drops from error to warn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

* fix(arcim): entity line needs the failed flag

The unlinked-credit-note row omitted `failed`, which the entityLines element
type requires. Caught by the zero-extensions build, not by vitest: the unit
suite does not typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

* fix(arcim): write the missing-reference disclosure onto the credit note itself

Review finding (swedish-compliance-review-bot): ML 17 kap 22-23 § wants a
kreditfaktura to reference the invoice it credits, and BFL 5 kap 6-7 § wants a
verifikation to reference its underlag. No provider DTO carries that reference,
so the pairing cannot be resolved at import and guessing it would corrupt the
AR ledger. Reporting the count in the migration wizard is not enough: a result
screen is not rakenskapsinformation, and the gap has to be legible on the
record itself years later.

The disclosure now goes into invoices.notes and supplier_invoices.notes,
preserving whatever note the provider sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:57:48 +02:00

1018 lines
40 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 {
AmountType,
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
/**
* True for an imported kreditfaktura that carries no pointer at the invoice
* it credits.
*
* `invoices` models that relation only through `credited_invoice_id`, and no
* provider DTO carries a reference to the credited invoice: SalesInvoiceDto
* and SupplierInvoiceDto (lib/providers/dto.ts) state the type through
* `invoiceTypeCode` 381 and nothing else. So every credit note the migration
* imports lands unlinked, and guessing the original from a number in a note
* or from the amount would put a wrong pair in the AR ledger. The row itself
* is complete räkenskapsinformation (reversed amounts, terminal status);
* only the pairing is missing. Meant to be counted into the migration
* summary the way `vatUnresolved` is, so the user is told instead of finding
* it out in the ledger; the orchestrator does not read it yet, and the copy
* that reports it waits in `ext_arcim_credit_notes_unlinked_detail`.
*/
creditNoteUnlinked: 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,
}
}
/** Reversed sign for a kreditfaktura amount, without producing -0. */
function negate(n: number): number {
return n === 0 ? 0 : -n
}
/**
* The same document stated in magnitudes.
*
* Providers disagree on the sign a kreditfaktura carries: Visma reports a
* credit invoice with a negative TotalAmount (lib/providers/visma/mapper.ts)
* while the arcim gateway states the magnitude beside invoiceTypeCode 381.
* Both have to land on the single convention Accounted stores, so the amounts
* are resolved from the magnitudes and the credit sign is applied once, at the
* end. It is also what lets resolveInvoiceVat classify the rate at all: it
* divides VAT by subtotal, which only yields a statutory rate when both are
* positive.
*/
function withAbsoluteAmounts(dto: SalesInvoiceDto): SalesInvoiceDto {
const abs = (amount: AmountType): AmountType => ({ ...amount, value: Math.abs(amount.value) })
return {
...dto,
lines: dto.lines.map((line) => ({
...line,
quantity: line.quantity != null ? Math.abs(line.quantity) : undefined,
unitPrice: line.unitPrice ? abs(line.unitPrice) : undefined,
lineExtensionAmount: abs(line.lineExtensionAmount),
taxAmount: line.taxAmount ? abs(line.taxAmount) : undefined,
})),
taxTotal: dto.taxTotal ? { ...dto.taxTotal, taxAmount: abs(dto.taxTotal.taxAmount) } : undefined,
legalMonetaryTotal: {
...dto.legalMonetaryTotal,
lineExtensionAmount: dto.legalMonetaryTotal.lineExtensionAmount
? abs(dto.legalMonetaryTotal.lineExtensionAmount)
: undefined,
payableAmount: abs(dto.legalMonetaryTotal.payableAmount),
},
}
}
export function mapSalesInvoice(
dto: SalesInvoiceDto,
userId: string,
companyId: string,
customerId: string,
fxRates?: FxRateIndex
): MappedInvoice {
const isCreditNote = dto.invoiceTypeCode === '381'
const amounts = isCreditNote ? withAbsoluteAmounts(dto) : dto
const sign = (n: number): number => (isCreditNote ? negate(n) : n)
const total = sign(round2(amounts.legalMonetaryTotal.payableAmount.value))
const vat = resolveInvoiceVat(amounts)
const subtotal = sign(vat.subtotal)
const vatAmount = sign(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',
}
// A kreditfaktura is never an open or a paid receivable, so it gets a
// terminal status regardless of the provider's lifecycle status:
// invoiceTypeCode is the only signal that the document IS a credit note, and
// the arcim gateway is not guaranteed to also send status='credited'. Same
// reasoning as mapSupplierInvoice.
const status = isCreditNote ? 'credited' : (statusMap[dto.status] || 'sent')
// Nothing is ever collected on a kreditfaktura: it reduces what the customer
// owes rather than settling anything. This also keeps the row clear of
// invoices_credit_note_not_paid, which forbids paid/partially_paid the moment
// the row points at the invoice it credits.
const settlement = isCreditNote
? { paidAt: null as string | null, paidAmount: 0, remainingAmount: 0 }
: {
paidAt: dto.paymentStatus.paid
? dto.paymentStatus.lastPaymentDate || dto.issueDate
: null,
paidAmount: dto.paymentStatus.paid
? total
: round2(total - dto.paymentStatus.balance.value),
remainingAmount: dto.paymentStatus.paid
? 0
: Math.max(0, round2(dto.paymentStatus.balance.value)),
}
// 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,
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: isCreditNote ? creditNoteUnlinkedNote(dto.note) : (dto.note || null),
// Always 'invoice'. invoices_document_type_check allows only
// ('invoice', 'proforma', 'delivery_note'), and Accounted models a
// kreditfaktura as an invoice row with reversed amounts plus
// credited_invoice_id, not as a document type of its own (see
// app/api/invoices/route.ts and .../invoices/[id]/credit/route.ts).
// Writing 'credit_note' here made Postgres reject every migrated
// kreditfaktura with a 23514, and the run counted each one as skipped.
document_type: 'invoice',
paid_at: settlement.paidAt,
paid_amount: settlement.paidAmount,
// remaining_amount is NOT NULL DEFAULT 0, so omitting it makes every
// migrated open invoice look fully settled in AR aging.
remaining_amount: settlement.remainingAmount,
}
const items = amounts.lines.map((line, idx) => mapSalesInvoiceLine(line, idx, vat.rate, isCreditNote))
return {
invoice,
items,
fxUnresolved: fx.unresolved,
vatUnresolved: vat.unresolved,
creditNoteUnlinked: isCreditNote,
}
}
/**
* Durable note for a migrated kreditfaktura that carries no pointer at the
* invoice it credits.
*
* ML 17 kap 22-23 § requires a kreditfaktura to reference the original
* invoice, and BFL 5 kap 6-7 § requires a verifikation to reference its
* underlag. No provider DTO carries that reference (lib/providers/dto.ts), so
* the pairing cannot be resolved at import time and guessing it would corrupt
* the AR ledger. The wizard reports the count, but a wizard result screen is
* not rakenskapsinformation: the gap has to be legible on the record itself,
* years later, to whoever opens the invoice. So it is written into `notes`,
* preserving whatever note the provider sent.
*/
function creditNoteUnlinkedNote(providerNote: string | null | undefined): string {
const disclosure =
'Kreditfaktura importerad vid systembyte. Referens till ursprungsfakturan '
+ 'saknas: kallsystemet skickade ingen sadan referens vid migreringen.'
const existing = (providerNote || '').trim()
return existing ? `${existing}\n\n${disclosure}` : disclosure
}
/**
* 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,
isCreditNote: boolean,
): 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)
// A kreditfaktura reverses quantity, line total and VAT and keeps the unit
// price and the rate positive: exactly what buildCreditNoteItem writes for a
// credit note issued in-app (lib/invoices/build-credit-note-item.ts).
const sign = (n: number): number => (isCreditNote ? negate(n) : n)
return {
sort_order: index + 1,
description: line.description || line.itemName || '',
quantity: sign(line.quantity || 1),
unit: line.unitCode || 'st',
unit_price: round2(line.unitPrice?.value ?? line.lineExtensionAmount.value),
line_total: sign(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: sign(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: isCreditNote ? creditNoteUnlinkedNote(dto.note) : (dto.note || null),
}
const items = dto.lines.map((line, idx) => mapSupplierInvoiceLine(line, idx, vat.rate))
return {
invoice,
items,
fxUnresolved: fx.unresolved,
vatUnresolved: vat.unresolved,
// supplier_invoices carries is_credit_note, so the row still reads as a
// kreditfaktura on its own; what is missing is the same pointer at the
// original that the sales side lacks.
creditNoteUnlinked: isCreditNote,
}
}
/**
* 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,
}
}