Files
accounted/lib/bookkeeping/currency-utils.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

112 lines
4.4 KiB
TypeScript

/**
* Currency conversion helpers for journal entry generators.
*
* All journal entry line amounts (debit_amount / credit_amount) must be in SEK.
* These helpers resolve the correct SEK amount from the various currency fields
* available on invoices, transactions, and supplier invoices.
*/
/**
* Resolve the SEK amount for a journal entry line, or null when the row carries
* no honest way to express it in kronor.
*
* This is the single conversion ladder for the whole codebase. Prefer it over
* `resolveSekAmount()` in any code that WRITES to the ledger.
*
* Priority:
* 1. Currency is SEK (or unset, which means SEK) → the amount already IS kronor
* 2. amountSek is populated → the pre-computed SEK value, rounded to öre
* 3. A positive exchangeRate is available → amount * exchangeRate, rounded to öre
* 4. Otherwise → null. A foreign amount with neither a stored SEK value nor a
* rate has no SEK value, and saying so is the only honest answer.
*
* Why null and not the raw amount: returning the foreign number relabels 100 EUR
* as 100 SEK. On a booking path every leg is then scaled by the same wrong
* factor, so the verifikation still balances, no DB trigger fires and nothing
* errors: a 1 000 EUR sale posts 1 000 kr to 3001 and 250 kr to 2611 instead of
* 11 500 kr and 2 875 kr at 11,50 SEK/EUR. That understates ruta 05 and ruta 10
* of the momsdeklaration by the same amount, which is an oriktig uppgift exposed
* to skattetillägg under SFL 49 kap 4 §. Nothing downstream can detect it,
* because the wrong number is indistinguishable from a correct one by then.
*
* The in-house precedent for refusing instead of guessing is the
* `match_batch_allocate` RPC (hard-fails with BATCH_FX_RATE_MISSING),
* `toSekOrThrow()` in supplier-invoice-entries.ts (SI_FX_RATE_MISSING),
* `itemToSekOrThrow()` in invoice-entries.ts (INVOICE_FX_RATE_MISSING) and
* lib/reports/supplier-ledger.ts, which skips unconvertible FX rows and counts
* them in `unconverted_fx_count` rather than faking a 1:1 conversion.
*/
export function resolveSekAmountOrNull(
amount: number,
amountSek: number | null | undefined,
currency: string | null | undefined,
exchangeRate: number | null | undefined
): number | null {
if (!currency || currency === 'SEK') {
return amount
}
if (amountSek != null) {
return Math.round(amountSek * 100) / 100
}
if (exchangeRate != null && exchangeRate > 0) {
return Math.round(amount * exchangeRate * 100) / 100
}
return null
}
/**
* Lenient variant of `resolveSekAmountOrNull()` that falls back to the raw
* amount when no SEK value can be established.
*
* READ-ONLY CODE ONLY. Do not introduce new callers on a path that writes
* journal entry lines; use `resolveSekAmountOrNull()` and refuse (or skip) when
* it returns null.
*
* The fallback exists for legacy data: rows predating the FX columns genuinely
* have neither `*_sek` nor an `exchange_rate`, they are overwhelmingly SEK, and
* a report must still render them rather than blank out a whole ledger. Every
* remaining caller in `lib/reports/` establishes that the row is convertible
* BEFORE calling (see the `isFx && !hasRate` checks in ar-ledger.ts,
* supplier-ledger.ts, ar-reconciliation.ts, supplier-reconciliation.ts and
* kpi.ts), so for them the fallback is already unreachable and this function is
* just the shared arithmetic.
*
* Behaviour is deliberately unchanged from before the strict sibling existed, so
* that no existing call site shifts.
*/
export function resolveSekAmount(
amount: number,
amountSek: number | null | undefined,
currency: string | null | undefined,
exchangeRate: number | null | undefined
): number {
return resolveSekAmountOrNull(amount, amountSek, currency, exchangeRate) ?? amount
}
/**
* Build currency metadata fields for a journal entry line.
* Returns an empty object for SEK transactions (no metadata needed).
*/
export function buildCurrencyMetadata(
currency: string | null | undefined,
amountInCurrency: number | null | undefined,
exchangeRate: number | null | undefined
): {
currency?: string
amount_in_currency?: number
exchange_rate?: number
} {
if (!currency || currency === 'SEK') {
return {}
}
return {
...(currency ? { currency } : {}),
...(amountInCurrency != null ? { amount_in_currency: amountInCurrency } : {}),
...(exchangeRate != null && exchangeRate > 0 ? { exchange_rate: exchangeRate } : {}),
}
}