Files
accounted/lib/bookkeeping/proposal-lines.ts
T
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

461 lines
21 KiB
TypeScript

/**
* Proposed-kontering line computation, shared by the proposal preview and the
* "Andra rader" hand-off into the manual booking dialog.
*
* `computeProposalLines()` is the single source of what a proposed booking
* (AI suggestion, static template, counterparty template with or without a
* line pattern) looks like: JournalEntryPreview renders exactly these lines,
* and `proposalLinesToFormLines()` converts the same lines into the
* JournalEntryForm prefill shape so what the user saw is what they edit.
*
* ENGINE PARITY IS THE CONTRACT. Because the prefill is bookable, every
* branch here must reproduce, to the ore, what the corresponding engine path
* books for the same proposal:
*
* - category branch -> buildMappingResultFromCategory (category-mapping.ts)
* - static template -> buildMappingResultFromTemplate (booking-templates.ts)
* - legacy counterparty -> buildMappingResultFromCounterpartyTemplate's
* legacy single-pair path (counterparty-templates.ts)
* - line pattern -> buildMultiLineMappingResult (counterparty-templates.ts)
* - line assembly/nets -> buildTransactionEntryLines (transaction-entries.ts)
*
* That is why VAT is single-rounded and the net leg is ALWAYS gross minus the
* rounded VAT (never independently rounded: at 12% both halves round up for
* gross = 14 mod 28 ore and the entry goes off by 1 ore), why the fiktiv-moms
* pair uses the engine's plain rounding (roundOre's EPSILON nudge diverges at
* exact-half floats like 8.62 * 0.25), and why sign-mismatched counterparty
* matches are mirrored exactly as the server mirrors them. The 3740 rounding
* leg follows the engine's sign rule too: business side when the ratios
* under-allocate, opposite side when they over-allocate (#1898).
*
* The resulting booking still goes through JournalEntryForm's normal manual
* validation and the bookkeeping engine: nothing here writes to the ledger.
*/
import { getVatRate } from '@/lib/bookkeeping/vat-entries'
import { getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
import { buildCurrencyMetadata } from '@/lib/bookkeeping/currency-utils'
import { roundOre } from '@/lib/money'
import {
legacyTemplateDirection as legacyDirection,
patternDirection,
} from '@/lib/bookkeeping/counterparty-templates'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { TransactionCategory, VatTreatment, EntityType, LinePatternEntry } from '@/types'
/**
* The engine's ore rounding, byte-identical to the Math.round(x*100)/100 the
* booking paths above use. Deliberately NOT roundOre(): its Number.EPSILON
* nudge rounds exact-half floats (8.62 * 0.25 = 2.155) up where the engine
* rounds down, and a prefill that differs from the engine by 1 ore is a
* refuted bug, not an improvement. Do not "fix" this to roundOre.
*/
function engineRound(n: number): number {
return Math.round(n * 100) / 100
}
export interface ProposalLine {
side: 'debet' | 'kredit'
account: string
amount: number
/**
* True for the bank/settlement leg (the money side). The prefill stamps
* currency metadata on this leg, and swaps in the transaction's resolved
* cash account ONLY when the leg is the literal default '1930': the same
* contract as the engine's applySettlementAccount (mapping-engine.ts),
* which never rewrites a learned non-1930 money account.
*/
settlement?: boolean
}
export interface ProposalLinesInput {
amount: number
/**
* SEK-equivalent of `amount` for foreign-currency transactions. When set,
* all line calculations use this value: the verifikation must always be in
* SEK regardless of the source currency. Falls back to `amount` when
* omitted (i.e. SEK transactions).
*/
amountSek?: number
category?: TransactionCategory
/**
* Explicit VAT treatment. Pass the WIRE value (after resolveExplicitVat):
* an undefined lets the mapping derive the category default, 'exempt'
* books no VAT. 'none' is tolerated and collapses to undefined for
* backward safety, but callers should resolve it first: the raw UI 'none'
* is ambiguous (seeded default vs explicit no-VAT deviation) and passing
* it unresolved previews VAT the confirm path would never book.
*/
vatTreatment?: VatTreatment | 'none'
accountOverride?: string
entityType?: EntityType
/**
* For template-based bookings: overrides category mapping. Callers must
* pass the entity-resolved accounts (debit_account_ab/credit_account_ab
* for aktiebolag), mirroring buildMappingResultFromTemplate.
*
* For linePattern bookings these carry the counterparty template's learned
* legacy pair AS STORED (no entity resolution): the engine takes the
* settlement leg's account from that pair (credit for an expense, debit
* for an income, mirror-swapped), so the prefill must too.
*/
templateDebitAccount?: string
templateCreditAccount?: string
templateVatRate?: number
templateVatTreatment?: VatTreatment | null
templateSupplierType?: 'eu_business' | 'non_eu_business' | 'swedish_business'
/**
* Legacy single-pair counterparty template (learned pair, no line_pattern):
* routes the template accounts through the engine's legacy counterparty
* semantics instead of the static-template ones: VAT from
* templateVatTreatment on EXPENSES only (incl. the 2645/2614 fiktiv-moms
* pair for reverse charge, without the basbelopp pair the static path
* emits), income booked gross without VAT legs, and sign-mismatched
* matches mirrored. templateVatRate is ignored in this mode.
*/
counterpartyLegacy?: boolean
/** For multi-line counterparty template bookings */
linePattern?: LinePatternEntry[]
settlementAccount?: string
}
/**
* Resolve a static template's accounts for the company's entity type: the
* same substitution buildMappingResultFromTemplate performs before booking.
* Exposed so the proposal dialog resolves the accounts it shows and hands
* over, instead of previewing EF accounts to an aktiebolag.
*/
export function resolveTemplateAccountsForEntity(
template: {
debit_account?: string
credit_account?: string
debit_account_ab?: string
credit_account_ab?: string
},
entityType: EntityType | undefined,
): { debitAccount?: string; creditAccount?: string } {
if (entityType === 'aktiebolag') {
return {
debitAccount: template.debit_account_ab ?? template.debit_account,
creditAccount: template.credit_account_ab ?? template.credit_account,
}
}
return { debitAccount: template.debit_account, creditAccount: template.credit_account }
}
/**
* Compute the concrete verifikation lines a proposal amounts to: what the
* engine will book for this proposal, expressed as display/prefill lines.
*/
export function computeProposalLines(input: ProposalLinesInput): ProposalLine[] {
const {
amount,
amountSek,
category,
vatTreatment,
accountOverride,
entityType = 'enskild_firma',
templateDebitAccount,
templateCreditAccount,
templateVatRate,
templateVatTreatment,
templateSupplierType,
counterpartyLegacy,
linePattern,
settlementAccount = '1930',
} = input
const result: ProposalLine[] = []
// Use SEK-equivalent when provided; sign comes from `amount` (which
// distinguishes income vs expense) but magnitude always comes from SEK.
const absAmount = Math.abs(amountSek ?? amount)
const isIncome = amount > 0
// ---- Multi-line counterparty template (buildMultiLineMappingResult) ----
if (linePattern && linePattern.length > 0) {
// Sign mismatch (refund/repayment): the engine flips every learned side
// so the mirrored entry reduces what the original pattern built up.
const learned = patternDirection(linePattern)
const mirror =
(learned === 'expense' && isIncome) || (learned === 'income' && !isIncome)
const side = (s: 'debit' | 'credit'): 'debet' | 'kredit' => {
const effective = mirror ? (s === 'debit' ? 'credit' : 'debit') : s
return effective === 'debit' ? 'debet' : 'kredit'
}
// Settlement line: gross on the bank side of the transaction's sign.
// ENGINE PARITY for the money leg's ACCOUNT: buildTransactionEntryLines
// books it on mappingResult.credit_account for an expense and
// debit_account for an income, and buildMultiLineMappingResult fills that
// pair from the template's learned legacy accounts, swapped under mirror
// (a pattern learned from vouchers settling on 2440/1510/19xx keeps that
// account; applySettlementAccount only ever rewrites a literal 1930).
// The caller passes the learned pair via templateDebitAccount /
// templateCreditAccount; without it we fall back to the swappable 1930
// default exactly like the engine's `|| '1930'`.
const patternSettlementAccount =
(isIncome !== mirror ? templateDebitAccount : templateCreditAccount) || settlementAccount
result.push({
side: isIncome ? 'debet' : 'kredit',
account: patternSettlementAccount,
amount: absAmount,
settlement: true,
})
// VAT lines first (from rate, exact)
let totalVat = 0
for (const entry of linePattern) {
if (entry.type === 'vat' && entry.vat_rate) {
const vatAmt = engineRound(absAmount * entry.vat_rate / (1 + entry.vat_rate))
totalVat += vatAmt
result.push({ side: side(entry.side), account: entry.account, amount: vatAmt })
}
}
// Business/tax lines (from ratio against non-VAT amount)
const nonVatAmt = engineRound(absAmount - totalVat)
let allocated = 0
for (const entry of linePattern) {
if ((entry.type === 'business' || entry.type === 'tax') && entry.ratio !== undefined) {
const amt = engineRound(nonVatAmt * entry.ratio)
allocated += amt
result.push({ side: side(entry.side), account: entry.account, amount: amt })
}
}
// Rounding difference to 3740
const totalAllocated = engineRound(totalVat + allocated)
const diff = engineRound(absAmount - totalAllocated)
if (diff !== 0) {
// ENGINE PARITY (#1898, buildMultiLineMappingResult step 4): a positive
// diff (under-allocation) lands on the mirrored business side, a
// negative diff (over-allocation) on the opposite side. The flip is
// applied AFTER the mirror, exactly as the engine does it.
const businessSide = linePattern.find(e => e.type === 'business')?.side ?? 'credit'
const effectiveBusinessSide = side(businessSide)
const roundingSide: 'debet' | 'kredit' =
diff > 0 ? effectiveBusinessSide : (effectiveBusinessSide === 'debet' ? 'kredit' : 'debet')
result.push({ side: roundingSide, account: '3740', amount: Math.abs(diff) })
}
return result
}
// ---- Legacy single-pair counterparty template ----
// Mirrors buildMappingResultFromCounterpartyTemplate's legacy path plus
// buildTransactionEntryLines' net assembly: VAT legs on expenses only
// (reverse charge = the 2645/2614 pair alone, no basbelopp: a learned
// voucher that HAD basbelopp lines would have become a line_pattern), and
// sign mismatches mirrored via buildLegacyMismatchResult.
if (counterpartyLegacy && templateDebitAccount && templateCreditAccount) {
const treatment = templateVatTreatment ?? null
const learned = legacyDirection(templateDebitAccount, templateCreditAccount)
const mismatch =
(learned === 'expense' && isIncome) || (learned === 'income' && !isIncome)
if (!mismatch) {
if (!isIncome) {
// Expense: net business leg + VAT legs + gross settlement credit.
if (treatment === 'reverse_charge') {
const rcVatAmt = engineRound(absAmount * 0.25)
result.push({ side: 'debet', account: templateDebitAccount, amount: absAmount })
result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount, settlement: true })
result.push({ side: 'debet', account: '2645', amount: rcVatAmt })
result.push({ side: 'kredit', account: '2614', amount: rcVatAmt })
} else {
const vatRate = treatment ? getVatRate(treatment) : 0
const vatAmt = vatRate > 0 ? engineRound(absAmount * vatRate / (1 + vatRate)) : 0
const netAmt = vatAmt > 0 ? engineRound(absAmount - vatAmt) : absAmount
result.push({ side: 'debet', account: templateDebitAccount, amount: netAmt })
if (vatAmt > 0) {
result.push({ side: 'debet', account: '2641', amount: vatAmt })
}
result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount, settlement: true })
}
} else {
// Income: the legacy path emits no VAT lines for income (VAT is
// gated on isExpense server-side), so gross on both legs.
result.push({ side: 'debet', account: templateDebitAccount, amount: absAmount, settlement: true })
result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount })
}
return result
}
// Sign mismatch: accounts swap sides (buildLegacyMismatchResult).
if (isIncome) {
// Refund of an expense-learned pair: settle debit against the bank,
// reduce the business account, mirror the VAT legs.
if (treatment === 'reverse_charge') {
const rcVatAmt = engineRound(absAmount * 0.25)
result.push({ side: 'debet', account: templateCreditAccount, amount: absAmount, settlement: true })
result.push({ side: 'kredit', account: templateDebitAccount, amount: absAmount })
result.push({ side: 'kredit', account: '2645', amount: rcVatAmt })
result.push({ side: 'debet', account: '2614', amount: rcVatAmt })
} else {
const vatRate = treatment ? getVatRate(treatment) : 0
const vatAmt = vatRate > 0 ? engineRound(absAmount * vatRate / (1 + vatRate)) : 0
const netAmt = vatAmt > 0 ? engineRound(absAmount - vatAmt) : absAmount
result.push({ side: 'debet', account: templateCreditAccount, amount: absAmount, settlement: true })
result.push({ side: 'kredit', account: templateDebitAccount, amount: netAmt })
if (vatAmt > 0) {
result.push({ side: 'kredit', account: '2641', amount: vatAmt })
}
}
} else {
// Outgoing repayment against an income-learned pair: gross both ways,
// no VAT legs (server emits VAT only for !isExpense mismatches).
result.push({ side: 'debet', account: templateCreditAccount, amount: absAmount })
result.push({ side: 'kredit', account: templateDebitAccount, amount: absAmount, settlement: true })
}
return result
}
// ---- Static template (buildMappingResultFromTemplate) ----
if (templateDebitAccount && templateCreditAccount) {
const vatRate = templateVatRate ?? 0
// Single-rounded VAT, net by subtraction: the engine computes the VAT leg
// once (generateInputVatLine / the output-VAT branch) and derives the net
// as gross minus that VAT (transaction-entries.ts). Independently rounding
// net and VAT (the old extractNet/extractVat pair) goes off by 1 ore at
// 12% whenever gross = 14 mod 28 ore.
const vatAmt = vatRate > 0 ? engineRound(absAmount * vatRate / (1 + vatRate)) : 0
const netAmt = vatAmt > 0 ? engineRound(absAmount - vatAmt) : absAmount
const isReverseCharge = templateVatTreatment === 'reverse_charge' && !isIncome
if (isIncome) {
// Income: debit bank gross, credit revenue net, credit output VAT
result.push({ side: 'debet', account: templateDebitAccount, amount: absAmount, settlement: true })
result.push({ side: 'kredit', account: templateCreditAccount, amount: netAmt })
if (vatAmt > 0) {
// Map rate -> output VAT account (BAS 2611/2621/2631)
const outputVatAccount = vatRate === 0.06 ? '2631' : vatRate === 0.12 ? '2621' : '2611'
result.push({ side: 'kredit', account: outputVatAccount, amount: vatAmt })
}
} else if (isReverseCharge) {
// Expense with reverse charge: full reverse-charge verifikation
// (must match engine output in buildMappingResultFromTemplate).
const rcRate = 0.25
const rcVatAmt = engineRound(absAmount * rcRate)
const supplierType = templateSupplierType ?? 'eu_business'
const isDomestic = supplierType === 'swedish_business'
// Expense gross + bank
result.push({ side: 'debet', account: templateDebitAccount, amount: absAmount })
result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount, settlement: true })
// Fiktiv moms pair: 2645 (or 2647 domestic) / 2614
result.push({ side: 'debet', account: isDomestic ? '2647' : '2645', amount: rcVatAmt })
result.push({ side: 'kredit', account: '2614', amount: rcVatAmt })
// Basbelopp pair: 44xx|45xx / 4598, populates rutor 20-24.
// Skip if the debit account is already a basis account.
if (!/^4[45]\d{2}$/.test(templateDebitAccount)) {
const basisAccount =
supplierType === 'eu_business' ? '4535'
: supplierType === 'non_eu_business' ? '4531'
: '4425'
result.push({ side: 'debet', account: basisAccount, amount: absAmount })
result.push({ side: 'kredit', account: '4598', amount: absAmount })
}
} else {
// Expense: debit expense net + input VAT, credit bank gross
result.push({ side: 'debet', account: templateDebitAccount, amount: netAmt })
if (vatAmt > 0) {
result.push({ side: 'debet', account: '2641', amount: vatAmt })
}
result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount, settlement: true })
}
return result
}
// ---- Category-based (incl. AI suggestion) ----
if (!category) return result
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
const mapping = getCategoryAccountMapping(category, amount, category !== 'private', entityType, resolvedVat)
const debitAccount = accountOverride && amount < 0 ? accountOverride : mapping.debitAccount
const creditAccount = accountOverride && amount > 0 ? accountOverride : mapping.creditAccount
const treatment = mapping.vatTreatment as VatTreatment | null
const vatRate = treatment ? getVatRate(treatment) : 0
// buildMappingResultFromCategory computes the VAT leg with roundOre and the
// net as gross minus that leg (transaction-entries.ts).
const vatAmt = vatRate > 0 ? roundOre(absAmount * vatRate / (1 + vatRate)) : 0
if (amount < 0) {
// Expense: Debit expense + VAT, Credit bank. The net leg carries the full
// gross when no VAT line is emitted (matches the engine's no-VAT branch).
const hasVatLine = vatAmt > 0 && !!mapping.vatDebitAccount
const netAmt = hasVatLine ? engineRound(absAmount - vatAmt) : absAmount
result.push({ side: 'debet', account: debitAccount, amount: netAmt })
if (hasVatLine && mapping.vatDebitAccount) {
result.push({ side: 'debet', account: mapping.vatDebitAccount, amount: vatAmt })
}
result.push({ side: 'kredit', account: creditAccount, amount: absAmount, settlement: true })
} else {
// Income: Debit bank, Credit revenue + VAT
const hasVatLine = vatAmt > 0 && !!mapping.vatCreditAccount
const netAmt = hasVatLine ? engineRound(absAmount - vatAmt) : absAmount
result.push({ side: 'debet', account: debitAccount, amount: absAmount, settlement: true })
if (hasVatLine && mapping.vatCreditAccount) {
result.push({ side: 'kredit', account: mapping.vatCreditAccount, amount: vatAmt })
}
result.push({ side: 'kredit', account: creditAccount, amount: netAmt })
}
// Reverse charge: add offsetting lines (generateReverseChargeLines)
if (treatment === 'reverse_charge' && amount < 0) {
const rcVatAmt = engineRound(absAmount * 0.25)
result.push({ side: 'debet', account: '2645', amount: rcVatAmt })
result.push({ side: 'kredit', account: '2614', amount: rcVatAmt })
}
return result
}
/**
* Convert computed proposal lines into JournalEntryForm prefill lines: the
* same hand-off shape buildInitialLinesFromTemplate produces for library
* templates. Amounts arrive already ore-rounded from computeProposalLines;
* toFixed(2) here only formats the input-field string (same pattern as
* applyTemplate / buildInitialLines), it is not money math.
*/
export function proposalLinesToFormLines(
lines: ProposalLine[],
opts: {
/**
* Resolved cash account: replaces the settlement leg's account ONLY when
* that leg is the literal default '1930', mirroring the engine's
* applySettlementAccount. A learned non-1930 money leg (1510, 2440,
* 2890, another 19xx) is authoritative and is never rewritten.
*/
settlementAccount?: string
currency?: string | null
/** Foreign-currency amount of the transaction (absolute). */
foreignAmount?: number | null
exchangeRate?: number | null
} = {},
): FormLine[] {
const currencyMeta = buildCurrencyMetadata(opts.currency, opts.foreignAmount, opts.exchangeRate)
return lines.map((line) => {
const amount = roundOre(line.amount)
const amountStr = amount.toFixed(2)
const isSettlement = line.settlement === true
const swapAccount = isSettlement && line.account === '1930' && !!opts.settlementAccount
return {
account_number: swapAccount && opts.settlementAccount ? opts.settlementAccount : line.account,
debit_amount: line.side === 'debet' ? amountStr : '',
credit_amount: line.side === 'kredit' ? amountStr : '',
line_description: '',
// Currency metadata belongs on the money leg only, mirroring
// buildTransactionEntryLines' settlement handling.
...(isSettlement ? currencyMeta : {}),
}
})
}