26e29f47bc
* 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>
460 lines
22 KiB
TypeScript
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 : {}),
|
|
}
|
|
})
|
|
}
|