Files
MattssonandClaude Fable 5.1 26e29f47bc feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) (#2423)
* feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1)

Why the problem occurred: the legal form was modelled as a binary flag in
~300 files. `EntityType` was a two-member union, but nothing dispatched on it
exhaustively: 28 sites defaulted `?? 'enskild_firma'` (invoice, categorize,
match, stripe, invoice-inbox) or `?? 'aktiebolag'` (year-end, bokslut,
MCP), and every form-dependent choice was an `=== 'aktiebolag' ? A : B`
ternary. Widening the union compiled everywhere and changed nothing, so a
förening would have booked as an enskild firma in the app and as an
aktiebolag in bokslut and MCP, with no error anywhere. The lookup refused
föreningar at the door (mapEntityType returned null), which is what the
tester hit.

What was removed or simplified: the silent defaults. One module,
lib/company/entity-type.ts, now holds the list (ENTITY_TYPES), the parser
(never defaults), the resolver (settings hint, then companies.entity_type,
then throw) and `byEntityType`, whose Record arms make the compiler refuse
the next widening until each site has an answer. The form-dependent facts
(closing account, owner settlement account, calendar-year lock, default
method, K1/K2 label, personnummer vs 16-prefix) live there once instead of
in the ternaries. On the SQL side supported_entity_types() replaces four
copies of the literal list in the create RPCs.

Why this shape and not the proposed one: the tracker asked for the enum
widening plus a chart; that alone was the dangerous version (compiles, books
wrong). Bundling stiftelse was considered and dropped: identical plumbing but
no chart block. Creation sits behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED so
the CHECK, RPCs and seed can ship now and the first partner is switched on
without a migration; the flag goes when Phase 2 (packs, INK3, årsbokslut,
Swish) lands on the tracker.

Domain choices (DECISIONS.md 2026-09-08, verify with an accountant before
Phase 2): result closes to 2069 with 2068 as prior-year carry; no owner
accounts, member settlement on 2890; accrual default; brutet räkenskapsår
allowed; K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org
number gets the 16 prefix.

Migration 20260908110835 widens the three CHECK constraints, adds
supported_entity_types(), re-creates the three create RPCs with the widened
guard and adds the förening block to seed_chart_of_accounts. Applied to
staging and covered by ideell-forening-entity-type.pg.test.ts.

Part of #2072

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh

* fix(company): close the förening paths the skeptic refuted (#2072)

Five refutations from the /skeptic pass on 7a05c54d2, each fixed at the
shared definition rather than the reported site:

1. Privately paid supplier invoices and the utlägg dialog resolved the owner
   account in lib/expenses/payer.ts with its own AB/EF ternary, so a förening
   member's invoice was built on 2893 and then refused by the expense-claim
   service (which already said 2890), burning an ankomstnummer. The helper now
   uses ownerSettlementAccount.
2. Booking templates substitute their `_ab` accounts only for an aktiebolag;
   the `private_expense` template kept its base 2013 for a förening. Template
   accounts now resolve through templateAccountForForm: EF base, AB override,
   förening base with owner accounts translated to 2890 (booking-templates.ts
   and proposal-lines.ts share it).
3. A VAT-registered förening with helårsmoms got no momsdeklaration deadline:
   the annual VAT rule bailed on anything but AB/EF. A förening is a juridisk
   person and follows the räkenskapsår schedule (SFL 26 kap 33 §), so the rule
   now keys on fiscalYearLockedToCalendar instead of the two literals; same in
   the MCP VAT report.
4. 2069 would have accumulated across years: the year-open omföring was
   AB-only with 2099/2098 hard-coded. planResultAppropriation now takes the
   pair from resultClosingAccounts (AB 2099 -> 2098, förening 2069 -> 2068)
   and skips forms with no carry (EF).
5. With the flag off, a registry lookup that returned "Ideell förening" was
   prefilled into the onboarding journey, the form picker was skipped and the
   create step answered "Ogiltig företagsform" with no way back. The
   journey, the BankID picker, the onboarding page and the MCP lookup now use
   mapSetupEntityType, which maps only creatable forms, so a flagged-off form
   falls through to the picker as before.

Also: form picker keeps its AB-first order; tests for each fix.

Part of #2072

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh

* chore(migrations): move ideell förening migration after main's latest version (20260908143051)

Two migrations landed on main after the branch forked; a lower version
would be skipped by the merge-time apply. Staging history row renamed to
match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh

* chore(skills): regenerate accounted-api reference for the widened entity_type enum

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 14:47:50 +02:00

460 lines
22 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 { templateAccountForForm } from '@/lib/company/entity-type'
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) return { debitAccount: template.debit_account, creditAccount: template.credit_account }
return {
debitAccount: templateAccountForForm(entityType, template.debit_account, template.debit_account_ab),
creditAccount: templateAccountForForm(entityType, template.credit_account, template.credit_account_ab),
}
}
/**
* 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 : {}),
}
})
}