feat(bookkeeping): edit the lines of a proposed kontering (Andra rader) (#1894)
* feat(bookkeeping): edit the lines of a proposed kontering via Andra rader Proposal views (AI suggestion, static template, counterparty template with or without a line pattern) previously offered only accept-or-start-over: the verifikation preview was pure rendering and the only line-editable path was library templates. This adds an "Andra rader" affordance to the proposal view in QuickReviewDialog that hands the COMPUTED lines (accounts, SEK amounts, VAT legs, exactly what the preview shows) into TransactionBookingDialog / JournalEntryForm as an editable prefill, reusing the same initialLines mechanism library templates already use. - lib/bookkeeping/proposal-lines.ts: line computation extracted from JournalEntryPreview into computeProposalLines() (single source for preview and prefill, so they cannot drift) plus proposalLinesToFormLines() mapping to the JournalEntryForm prefill shape. The settlement leg is flagged so the booking dialog swaps in the transaction's resolved cash account and stamps currency metadata, mirroring buildInitialLinesFromTemplate. - JournalEntryPreview now renders computeProposalLines() output unchanged. - TransactionBookingDialog accepts proposalLines (takes precedence over preselectedTemplate); the booking still goes through JournalEntryForm's normal manual validation and the engine, no validation bypassed. - Ore rounding funnels through roundOre(); guard baseline ratcheted down. - New strings in messages/sv.json and messages/en.json (tx_quick_review). - Unit tests for all three proposal branches incl. VAT legs, reverse charge, multi-line patterns, 3740 rounding diff and FX metadata. Fixes #1878 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): make the Andra rader prefill engine-exact (skeptic findings) Three skeptics refuted the first cut of #1878: the extracted preview math was a lossy approximation of the engine, and making it bookable made every loss a real booking defect. This commit closes each refuted scenario by mirroring the exact engine path per proposal branch: - Balance: VAT is single-rounded and the net leg is gross minus that VAT (transaction-entries.ts semantics). Independently rounded net+VAT went off by 1 ore for 12% grosses at 14 mod 28 ore (e.g. 102.06, 100.94), prefillling an unbookable verifikat. - 'Ingen moms' deviation: the dialog resolves the UI 'none' sentinel via resolveExplicitVat before computing lines, so an explicit no-VAT choice prefills no VAT line instead of re-deriving the 25% category default into a bookable 2641 leg (ruta 48 inflation on e.g. loan repayments). - Ore parity: engineRound (plain Math.round(x*100)/100, matching the engine) replaces roundOre where the engine is naive; roundOre kept only where the engine uses it (category VAT leg). No more 1-ore drift between preview, prefill and the booked verifikat (8.62 RC, 34.30@12%). - Legacy counterparty pairs: new counterpartyLegacy mode mirrors the legacy booking path: reverse charge emits the 2645/2614 fiktiv-moms pair (previously dropped: an RC expense would have booked without fiktiv moms, understating rutor 30/48), VAT on expenses only, income gross, and sign-mismatched matches mirrored like buildLegacyMismatchResult. - Pattern mirror: sign-mismatched line patterns flip learned sides like buildMultiLineMappingResult; ratio allocation filters business/tax types. - Entity accounts: static template accounts resolve debit/credit_account_ab for aktiebolag (resolveTemplateAccountsForEntity), so an AB no longer previews or books EF-only accounts like 2013. - Settlement swap: only a literal-1930 settlement leg is swapped to the resolved cash account (applySettlementAccount parity); learned non-1930 money legs (1510/2440/2890/19xx) stay authoritative. - FX: QuickReviewDialog hands its enriched transaction row to the booking dialog so the settlement leg's exchange_rate metadata matches the rate the SEK amounts were computed with. 34 unit tests incl. every skeptic counterexample; guard baseline ratcheted to 622 (below main's 626). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): line-pattern settlement leg uses the learned legacy pair (skeptic refutation) Two independent skeptics refuted the pattern branch: the engine books the money leg on the counterparty template's learned legacy account (credit for an expense, debit for an income, mirror-swapped, falling back to 1930), while the preview/prefill defaulted to 1930. A SIE-learned pattern settling on 2440 showed kredit 1930 in the preview but booked kredit 2440 on confirm. QuickReviewDialog now passes the learned pair raw (no entity resolution, engine parity) and computeProposalLines selects the settlement account exactly like buildTransactionEntryLines; the literal-1930 swap to the resolved cash account is unchanged. CodeRabbit findings declined deliberately (see DECISIONS.md): the 3740 rounding line keeps the engine's business-side placement for both diff signs (parity contract; an unbalanced set is rejected at commit), and the naiveOreRound baseline stays at 622 (engineRound is a documented parity exception). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1212,6 +1212,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-25] WhatsApp instant received-signal = emoji reaction (U+2705) sent from the webhook, not an extra text message: reactions add no chat bubble so the one-combined-ack-per-burst design survives; best-effort and not persisted as an outbound row (cosmetic, like mark-read), gated on the CHAT_ALLOWED_MIME_TYPES allowlist so junk never earns a checkmark.
|
||||
[2026-08-24] MCP lazy authentication (#1814 PR 2) lists the FULL default tool catalog to anonymous clients and only gates tools/call: the agent has to be able to name a protected tool to trigger the 401 challenge that opens the Connect (and signup) prompt; listing only public tools would hide the trigger. Descriptions are public documentation anyway.
|
||||
[2026-08-24] Public (pre-auth) MCP tools are the three documentation tools only (search_tools, list_skills, load_skill); org-number lookup stays behind the challenge for now because the TIC lookup lives in another extension and cross-extension imports are forbidden.
|
||||
[2026-08-25] Proposal prefill/preview (lib/bookkeeping/proposal-lines.ts) mirrors the ENGINE's formulas, not the historical preview: net leg = gross minus single-rounded VAT (independently rounded extractNet/extractVat unbalances 12% grosses at 14 mod 28 ore), plain Math.round ore parity via a local engineRound (roundOre's EPSILON nudge diverges from booked verifikat at exact-half floats like 8.62*0.25), legacy counterparty proposals now render the 2645/2614 fiktiv-moms pair the legacy booking path actually emits (the 2026-07-29 decision claimed preview/engine parity but the preview omitted the pair; once 'Andra rader' made the preview bookable, the omission would book RC expenses without fiktiv moms), sign-mismatched counterparty matches are mirrored like the server, static template accounts are entity-resolved (_ab), the 'none' VAT sentinel is resolved via resolveExplicitVat before line computation, and the settlement swap applies only to a literal 1930 leg (applySettlementAccount parity). Chosen over sourcing the prefill from MappingResult builders directly to keep the client dialog free of server-only inputs; skeptic counterexamples are locked in proposal-lines.test.ts.
|
||||
[2026-08-24] gnubok_create_company (#1814 PR 3) is a direct write with an explicit two-phase confirm (preview, then confirm=true) instead of a staged pending_operation: pending_operations rows are company-scoped and there is no company to attach the staging to before it exists.
|
||||
[2026-08-24] gnubok_connect_bank / gnubok_connect_skatteverket hand the user a browser link plus connection status instead of driving the PSD2 or Skatteverket flow from the MCP server: both extension handlers need a cookie session and BankID in a browser, and cross-extension imports are forbidden. The web app's /import?mode=psd2 and the skatteverket authorize route are the links.
|
||||
[2026-08-24] The MCP consent page pre-ticks companies:write for an account that has no company yet: that account is connecting in order to create a company, and a default that dead-ends on insufficient scope right after signup would be the worse default. Still an untickable checkbox, still bounded by the client's scope ceiling.
|
||||
@@ -1219,3 +1220,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-25] An enskild firma's first fiscal year must end on 31 December in the programmatic setup paths, mirroring the wizard's own rule text: the calendar-year mandate (BFL 3 kap. 1 §) is not lifted by the first-year extension.
|
||||
[2026-08-24] The OAuth AS metadata advertises client_id_metadata_document_supported (CIMD, #1814 PR 4) without fetching or validating the client's metadata document: authorize/token never keyed anything on client_id, the redirect_uri allowlist is the trust boundary, and CIMD only changes what Claude/Codex send as client_id (an HTTPS URL instead of a DCR-minted UUID). Fetching the document would add a network dependency to every consent for no gain in this design. DCR stays for ChatGPT.
|
||||
[2026-08-25] CIMD is NOT advertised after all (reverses the 2026-08-24 entry; CodeRabbit on #1866): the spec expects an AS that advertises client_id_metadata_document_supported to fetch the document and match redirect_uri exactly against it, and our authorize endpoint only checks the global allowlist. Advertising would claim a check we skip. Add the flag together with an SSRF-safe cached CIMD fetch + exact redirect matching (localhost port-agnostic for Claude Code/Codex); DCR is free for us (stateless register), so nothing is lost meanwhile.
|
||||
[2026-08-25] Proposal line-pattern settlement leg now takes the counterparty template's learned legacy pair (credit for expense, debit for income, mirror-swapped, || 1930), passed raw from QuickReviewDialog: two skeptics refuted the 1930 default (engine books e.g. 2440 from SIE-learned patterns; preview/prefill showed 1930). Declined CodeRabbit's two suggestions on #1894 deliberately: the 3740 rounding line keeps the engine's business-side placement for BOTH diff signs (parity contract; the engine's negative-diff imbalance cannot reach the ledger, commit_journal_entry rejects it; engine-side sign fix is a separate issue) and the naiveOreRound baseline stays raised to 622 (engineRound is a documented parity exception, not drift).
|
||||
|
||||
@@ -39,6 +39,7 @@ import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking
|
||||
import { resolveQuickReviewDefaults, type ReviewTemplate } from '@/lib/transactions/quick-review-defaults'
|
||||
import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library'
|
||||
import type { ProposalLine } from '@/lib/bookkeeping/proposal-lines'
|
||||
import type {
|
||||
TransactionWithInvoice,
|
||||
ViewMode,
|
||||
@@ -326,6 +327,9 @@ export default function TransactionsPage() {
|
||||
const [bookingDialogOpen, setBookingDialogOpen] = useState(false)
|
||||
const [bookingDialogTransaction, setBookingDialogTransaction] = useState<TransactionWithInvoice | null>(null)
|
||||
const [bookingDialogTemplate, setBookingDialogTemplate] = useState<BookingTemplateLibrary | null>(null)
|
||||
// "Andra rader" hand-off: the computed proposal lines from QuickReviewDialog,
|
||||
// prefilled into TransactionBookingDialog for per-line editing.
|
||||
const [bookingDialogProposalLines, setBookingDialogProposalLines] = useState<ProposalLine[] | null>(null)
|
||||
// Account picked from the template picker's "Konton" search results:
|
||||
// prefills the counter line when the manual booking dialog opens.
|
||||
const [bookingDialogAccount, setBookingDialogAccount] = useState<string | null>(null)
|
||||
@@ -2993,6 +2997,7 @@ export default function TransactionsPage() {
|
||||
setBookingDialogOpen(false)
|
||||
setBookingDialogTransaction(null)
|
||||
setBookingDialogTemplate(null)
|
||||
setBookingDialogProposalLines(null)
|
||||
if (matched) {
|
||||
toast({ title: 'Bankhändelsen kopplad', description: 'Ingen ny bokföring skapad.' })
|
||||
} else {
|
||||
@@ -3392,11 +3397,27 @@ export default function TransactionsPage() {
|
||||
if (templatePickerTransaction) {
|
||||
setBookingDialogTransaction(templatePickerTransaction)
|
||||
setBookingDialogTemplate(null)
|
||||
setBookingDialogProposalLines(null)
|
||||
setBookingDialogAccount(null)
|
||||
setBookingDialogOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
// "Andra rader" on the proposal view: close the review and reopen the
|
||||
// manual booking dialog prefilled with the exact lines the preview showed.
|
||||
// The transaction comes from the dialog itself (its enriched mirror), not
|
||||
// from quickReview state: an in-dialog SEK-rate backfill lives only on the
|
||||
// enriched row, and the booking dialog stamps the settlement leg's FX
|
||||
// metadata from that row's exchange_rate.
|
||||
function handleEditProposedLines(lines: ProposalLine[], transaction: TransactionWithInvoice) {
|
||||
setQuickReviewOpen(false)
|
||||
setBookingDialogTransaction(transaction)
|
||||
setBookingDialogTemplate(null)
|
||||
setBookingDialogAccount(null)
|
||||
setBookingDialogProposalLines(lines)
|
||||
setBookingDialogOpen(true)
|
||||
}
|
||||
|
||||
// Account picked from the template picker's "Konton" search group
|
||||
// (issue #1877): same route as "Bokför manuellt", with the picked account
|
||||
// prefilled on the counter line of the journal entry form.
|
||||
@@ -3404,6 +3425,7 @@ export default function TransactionsPage() {
|
||||
if (!templatePickerTransaction) return
|
||||
setBookingDialogTransaction(templatePickerTransaction)
|
||||
setBookingDialogTemplate(null)
|
||||
setBookingDialogProposalLines(null)
|
||||
setBookingDialogAccount(accountNumber)
|
||||
setTemplatePickerOpen(false)
|
||||
setBookingDialogOpen(true)
|
||||
@@ -3416,6 +3438,7 @@ export default function TransactionsPage() {
|
||||
if (!templatePickerTransaction) return
|
||||
setBookingDialogTransaction(templatePickerTransaction)
|
||||
setBookingDialogTemplate(raw)
|
||||
setBookingDialogProposalLines(null)
|
||||
setBookingDialogAccount(null)
|
||||
setTemplatePickerOpen(false)
|
||||
setBookingDialogOpen(true)
|
||||
@@ -3983,11 +4006,13 @@ export default function TransactionsPage() {
|
||||
setBookingDialogOpen(o)
|
||||
if (!o) {
|
||||
setBookingDialogTemplate(null)
|
||||
setBookingDialogProposalLines(null)
|
||||
setBookingDialogAccount(null)
|
||||
}
|
||||
}}
|
||||
transaction={bookingDialogTransaction}
|
||||
preselectedTemplate={bookingDialogTemplate}
|
||||
proposalLines={bookingDialogProposalLines}
|
||||
preselectedAccount={bookingDialogAccount}
|
||||
onBooked={handleTransactionBooked}
|
||||
/>
|
||||
@@ -4140,6 +4165,7 @@ export default function TransactionsPage() {
|
||||
counterpartyDefaultDimensions={quickReview?.defaultDimensions ?? null}
|
||||
onConfirm={handleQuickReviewConfirm}
|
||||
onChangeTemplate={handleChangeTemplate}
|
||||
onEditLines={handleEditProposedLines}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,193 +3,50 @@
|
||||
import { useMemo } from 'react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import { getVatRate, extractVatAmount, extractNetAmount } from '@/lib/bookkeeping/vat-entries'
|
||||
import { getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
|
||||
import type { TransactionCategory, VatTreatment, EntityType, LinePatternEntry } from '@/types'
|
||||
import { computeProposalLines } from '@/lib/bookkeeping/proposal-lines'
|
||||
import type { ProposalLinesInput } from '@/lib/bookkeeping/proposal-lines'
|
||||
|
||||
interface PreviewLine {
|
||||
side: 'debet' | 'kredit'
|
||||
account: string
|
||||
amount: number
|
||||
}
|
||||
export type JournalEntryPreviewProps = ProposalLinesInput
|
||||
|
||||
interface JournalEntryPreviewProps {
|
||||
amount: number
|
||||
/**
|
||||
* SEK-equivalent of `amount` for foreign-currency transactions. When set,
|
||||
* all line calculations and the displayed totals 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
|
||||
vatTreatment?: VatTreatment | 'none'
|
||||
accountOverride?: string
|
||||
entityType?: EntityType
|
||||
/** For template-based bookings: overrides category mapping */
|
||||
templateDebitAccount?: string
|
||||
templateCreditAccount?: string
|
||||
templateVatRate?: number
|
||||
templateVatTreatment?: VatTreatment | null
|
||||
templateSupplierType?: 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||||
/** For multi-line counterparty template bookings */
|
||||
linePattern?: LinePatternEntry[]
|
||||
settlementAccount?: string
|
||||
}
|
||||
export default function JournalEntryPreview(props: JournalEntryPreviewProps) {
|
||||
const {
|
||||
amount,
|
||||
amountSek,
|
||||
category,
|
||||
vatTreatment,
|
||||
accountOverride,
|
||||
entityType,
|
||||
templateDebitAccount,
|
||||
templateCreditAccount,
|
||||
templateVatRate,
|
||||
templateVatTreatment,
|
||||
templateSupplierType,
|
||||
counterpartyLegacy,
|
||||
linePattern,
|
||||
settlementAccount,
|
||||
} = props
|
||||
|
||||
export default function JournalEntryPreview({
|
||||
amount,
|
||||
amountSek,
|
||||
category,
|
||||
vatTreatment,
|
||||
accountOverride,
|
||||
entityType = 'enskild_firma',
|
||||
templateDebitAccount,
|
||||
templateCreditAccount,
|
||||
templateVatRate,
|
||||
templateVatTreatment,
|
||||
templateSupplierType,
|
||||
linePattern,
|
||||
settlementAccount = '1930',
|
||||
}: JournalEntryPreviewProps) {
|
||||
const lines = useMemo(() => {
|
||||
const result: PreviewLine[] = []
|
||||
// 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)
|
||||
|
||||
// Multi-line counterparty template preview
|
||||
if (linePattern && linePattern.length > 0) {
|
||||
const isIncome = amount > 0
|
||||
const settlementSide = isIncome ? 'debet' : 'kredit'
|
||||
|
||||
// Settlement line
|
||||
result.push({ side: settlementSide, account: settlementAccount, amount: absAmount })
|
||||
|
||||
// VAT lines first (from rate)
|
||||
let totalVat = 0
|
||||
for (const entry of linePattern) {
|
||||
if (entry.type === 'vat' && entry.vat_rate) {
|
||||
const vatAmt = Math.round(absAmount * entry.vat_rate / (1 + entry.vat_rate) * 100) / 100
|
||||
totalVat += vatAmt
|
||||
result.push({ side: entry.side === 'debit' ? 'debet' : 'kredit', account: entry.account, amount: vatAmt })
|
||||
}
|
||||
}
|
||||
|
||||
// Business/tax lines (from ratio against non-VAT amount)
|
||||
const nonVatAmt = Math.round((absAmount - totalVat) * 100) / 100
|
||||
let allocated = 0
|
||||
const ratioEntries = linePattern.filter(e => e.ratio !== undefined)
|
||||
for (const entry of ratioEntries) {
|
||||
const amt = Math.round(nonVatAmt * (entry.ratio ?? 0) * 100) / 100
|
||||
allocated += amt
|
||||
result.push({ side: entry.side === 'debit' ? 'debet' : 'kredit', account: entry.account, amount: amt })
|
||||
}
|
||||
|
||||
// Rounding difference to 3740
|
||||
const totalAllocated = Math.round((totalVat + allocated) * 100) / 100
|
||||
const diff = Math.round((absAmount - totalAllocated) * 100) / 100
|
||||
if (diff !== 0) {
|
||||
const businessSide = linePattern.find(e => e.type === 'business')?.side ?? 'credit'
|
||||
result.push({ side: businessSide === 'debit' ? 'debet' : 'kredit', account: '3740', amount: Math.abs(diff) })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Template-based preview
|
||||
if (templateDebitAccount && templateCreditAccount) {
|
||||
const vatRate = templateVatRate ?? 0
|
||||
const vatAmt = extractVatAmount(absAmount, vatRate)
|
||||
const netAmt = extractNetAmount(absAmount, vatRate)
|
||||
const isIncome = amount > 0
|
||||
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 })
|
||||
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 = Math.round(absAmount * rcRate * 100) / 100
|
||||
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 })
|
||||
|
||||
// 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 })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Category-based preview
|
||||
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
|
||||
const vatAmt = vatRate > 0 ? extractVatAmount(absAmount, vatRate) : 0
|
||||
const netAmt = vatRate > 0 ? extractNetAmount(absAmount, vatRate) : absAmount
|
||||
|
||||
if (amount < 0) {
|
||||
// Expense: Debit expense + VAT, Credit bank
|
||||
result.push({ side: 'debet', account: debitAccount, amount: netAmt })
|
||||
if (vatAmt > 0 && mapping.vatDebitAccount) {
|
||||
result.push({ side: 'debet', account: mapping.vatDebitAccount, amount: vatAmt })
|
||||
}
|
||||
result.push({ side: 'kredit', account: creditAccount, amount: absAmount })
|
||||
} else {
|
||||
// Income: Debit bank, Credit revenue + VAT
|
||||
result.push({ side: 'debet', account: debitAccount, amount: absAmount })
|
||||
if (vatAmt > 0 && mapping.vatCreditAccount) {
|
||||
result.push({ side: 'kredit', account: mapping.vatCreditAccount, amount: vatAmt })
|
||||
}
|
||||
result.push({ side: 'kredit', account: creditAccount, amount: netAmt })
|
||||
}
|
||||
|
||||
// Reverse charge: add offsetting lines
|
||||
if (treatment === 'reverse_charge' && amount < 0) {
|
||||
const rcVatAmt = Math.round(absAmount * 0.25 * 100) / 100
|
||||
result.push({ side: 'debet', account: '2645', amount: rcVatAmt })
|
||||
result.push({ side: 'kredit', account: '2614', amount: rcVatAmt })
|
||||
}
|
||||
|
||||
return result
|
||||
}, [amount, amountSek, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate, templateVatTreatment, templateSupplierType, linePattern, settlementAccount])
|
||||
// Line computation lives in lib/bookkeeping/proposal-lines.ts, shared with
|
||||
// the "Andra rader" prefill so preview and editable lines never drift.
|
||||
const lines = useMemo(
|
||||
() => computeProposalLines({
|
||||
amount,
|
||||
amountSek,
|
||||
category,
|
||||
vatTreatment,
|
||||
accountOverride,
|
||||
entityType,
|
||||
templateDebitAccount,
|
||||
templateCreditAccount,
|
||||
templateVatRate,
|
||||
templateVatTreatment,
|
||||
templateSupplierType,
|
||||
counterpartyLegacy,
|
||||
linePattern,
|
||||
settlementAccount,
|
||||
}),
|
||||
[amount, amountSek, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate, templateVatTreatment, templateSupplierType, counterpartyLegacy, linePattern, settlementAccount]
|
||||
)
|
||||
|
||||
if (lines.length === 0) return null
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ import { linkDocuments, formatFailedDocumentNames } from '@/lib/documents/link-d
|
||||
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle, Inbox, FileText, X } from 'lucide-react'
|
||||
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { getVatRate } from '@/lib/bookkeeping/vat-entries'
|
||||
import { computeProposalLines, resolveTemplateAccountsForEntity } from '@/lib/bookkeeping/proposal-lines'
|
||||
import type { ProposalLine, ProposalLinesInput } from '@/lib/bookkeeping/proposal-lines'
|
||||
import type { ReviewTemplate } from '@/lib/transactions/quick-review-defaults'
|
||||
import { resolveExplicitVat } from '@/lib/transactions/quick-review-defaults'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
@@ -61,6 +62,15 @@ interface QuickReviewDialogProps {
|
||||
dimensions?: Record<string, string>
|
||||
) => Promise<string | null>
|
||||
onChangeTemplate?: () => void
|
||||
/**
|
||||
* "Andra rader": hand the COMPUTED proposal lines (exactly what the
|
||||
* verifikation preview shows) to the parent, which routes them into
|
||||
* TransactionBookingDialog as an editable prefill. The transaction passed
|
||||
* back is the dialog's ENRICHED row (with any in-dialog SEK conversion
|
||||
* backfill): the parent must hand that one to the booking dialog so the
|
||||
* settlement leg's FX metadata carries the same rate the amounts used.
|
||||
*/
|
||||
onEditLines?: (lines: ProposalLine[], transaction: TransactionWithInvoice) => void
|
||||
}
|
||||
|
||||
export default function QuickReviewDialog({
|
||||
@@ -78,6 +88,7 @@ export default function QuickReviewDialog({
|
||||
counterpartyDefaultDimensions,
|
||||
onConfirm,
|
||||
onChangeTemplate,
|
||||
onEditLines,
|
||||
}: QuickReviewDialogProps) {
|
||||
const t = useTranslations('tx_quick_review')
|
||||
const tCat = useTranslations('tx_categories')
|
||||
@@ -262,6 +273,74 @@ export default function QuickReviewDialog({
|
||||
.map(([, code]) => code)
|
||||
.join(' · ')
|
||||
|
||||
// Static templates carry AB-specific accounts; the engine substitutes them
|
||||
// at booking time, so the preview and the prefill must show the same
|
||||
// substitution (an aktiebolag must never be handed 2013-style EF accounts).
|
||||
const entityAccounts = resolveTemplateAccountsForEntity(template ?? {}, entityType)
|
||||
|
||||
// The one proposal definition: rendered by JournalEntryPreview and, via
|
||||
// "Andra rader", computed into editable prefill lines. Building it once
|
||||
// guarantees the user edits exactly the lines they were shown, and every
|
||||
// branch mirrors the engine path that books the proposal (see
|
||||
// lib/bookkeeping/proposal-lines.ts).
|
||||
const proposalInput: ProposalLinesInput = {
|
||||
amount: tx.amount,
|
||||
amountSek: sekAmount,
|
||||
...(hasCounterpartyPattern
|
||||
? {
|
||||
linePattern: counterpartyLinePattern ?? undefined,
|
||||
// Engine parity for the money leg: buildTransactionEntryLines books
|
||||
// the settlement on the learned template's legacy pair (credit
|
||||
// account for an expense, debit for an income, mirror-swapped), not
|
||||
// on a default 1930. Raw accounts, not entity-resolved: learned
|
||||
// counterparty templates carry no _ab variants and the engine uses
|
||||
// them as stored.
|
||||
templateDebitAccount: template?.debit_account,
|
||||
templateCreditAccount: template?.credit_account,
|
||||
}
|
||||
: isTemplateBooking && template?.debit_account && template?.credit_account
|
||||
? isCounterpartyTemplate
|
||||
? {
|
||||
// Legacy counterparty pair: computeProposalLines mirrors the
|
||||
// legacy booking path (VAT incl. the 2645/2614 fiktiv-moms
|
||||
// pair on expenses only, no basbelopp, mismatches mirrored).
|
||||
templateDebitAccount: template.debit_account,
|
||||
templateCreditAccount: template.credit_account,
|
||||
templateVatTreatment: template.vat_treatment ?? null,
|
||||
counterpartyLegacy: true,
|
||||
}
|
||||
: {
|
||||
templateDebitAccount: entityAccounts.debitAccount ?? template.debit_account,
|
||||
templateCreditAccount: entityAccounts.creditAccount ?? template.credit_account,
|
||||
templateVatRate: template.vat_rate,
|
||||
templateVatTreatment: template.vat_treatment,
|
||||
templateSupplierType: template.reverse_charge_supplier_type,
|
||||
}
|
||||
: {
|
||||
category,
|
||||
// Send the WIRE value, not the UI sentinel: 'none' as a seeded
|
||||
// default stays undefined (server derives, no VAT for exempt
|
||||
// categories), 'none' as a deviation becomes explicit 'exempt'.
|
||||
// Passing raw 'none' made the mapping re-derive the category
|
||||
// default and preview (and, worse, prefill) 25% moms against an
|
||||
// explicit no-VAT choice: the exact collapse resolveExplicitVat
|
||||
// exists to prevent on the confirm path.
|
||||
vatTreatment: resolveExplicitVat(isLiabilityAccount ? 'none' : vatTreatment, defaultVat),
|
||||
accountOverride,
|
||||
entityType,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
// Computed once per render: gates the affordance (no lines, no link) and is
|
||||
// the exact payload the link hands over.
|
||||
const proposalLines = onEditLines ? computeProposalLines(proposalInput) : []
|
||||
|
||||
function handleEditLines() {
|
||||
if (!onEditLines || proposalLines.length === 0) return
|
||||
onEditLines(proposalLines, tx)
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!category || !transaction) return
|
||||
|
||||
@@ -505,9 +584,9 @@ export default function QuickReviewDialog({
|
||||
{/* Only when there IS a single debit/credit pair to show: a
|
||||
multi-line counterparty pattern has none, and a template that
|
||||
never carried accounts would render "D: → K: ". */}
|
||||
{!hasCounterpartyPattern && template?.debit_account && template?.credit_account && (
|
||||
{!hasCounterpartyPattern && entityAccounts.debitAccount && entityAccounts.creditAccount && (
|
||||
<p className="mt-1.5 text-xs font-mono text-muted-foreground">
|
||||
D: {formatAccountWithName(template.debit_account)} → K: {formatAccountWithName(template.credit_account)}
|
||||
D: {formatAccountWithName(entityAccounts.debitAccount)} → K: {formatAccountWithName(entityAccounts.creditAccount)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -545,33 +624,24 @@ export default function QuickReviewDialog({
|
||||
{/* Journal entry preview: hidden until we have a SEK conversion;
|
||||
otherwise we'd render a verifikation in the wrong currency. */}
|
||||
{!sekConversionMissing && !rateLoading && (
|
||||
<JournalEntryPreview
|
||||
amount={tx.amount}
|
||||
amountSek={sekAmount}
|
||||
{...(hasCounterpartyPattern
|
||||
? { linePattern: counterpartyLinePattern ?? undefined }
|
||||
: isTemplateBooking && template?.debit_account && template?.credit_account
|
||||
? {
|
||||
templateDebitAccount: template.debit_account,
|
||||
templateCreditAccount: template.credit_account,
|
||||
// A counterparty template carries a treatment but no rate,
|
||||
// and its legacy booking path emits an input-VAT leg from
|
||||
// that treatment only (no basbelopp pair), so it gets the
|
||||
// rate alone: passing the treatment too would preview
|
||||
// reverse-charge lines the engine never books.
|
||||
templateVatRate: isCounterpartyTemplate
|
||||
? (template.vat_treatment ? getVatRate(template.vat_treatment) : 0)
|
||||
: template.vat_rate,
|
||||
...(isCounterpartyTemplate
|
||||
? {}
|
||||
: {
|
||||
templateVatTreatment: template.vat_treatment,
|
||||
templateSupplierType: template.reverse_charge_supplier_type,
|
||||
}),
|
||||
}
|
||||
: { category, vatTreatment: isLiabilityAccount ? 'none' : vatTreatment, accountOverride, entityType }
|
||||
<div>
|
||||
<JournalEntryPreview {...proposalInput} />
|
||||
{/* "Andra rader": send the computed lines into the manual booking
|
||||
dialog for per-line editing. Offered on every proposal surface
|
||||
(AI suggestion, static template, counterparty pattern). */}
|
||||
{onEditLines && proposalLines.length > 0 && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={isProcessing}
|
||||
onClick={handleEditLines}
|
||||
>
|
||||
{t('edit_lines')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account & VAT: hidden for template bookings (accounts defined by the template) */}
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { AvailableInboxDoc } from '@/components/bookkeeping/InboxDocumentPi
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import { resolveSekAmount, buildCurrencyMetadata } from '@/lib/bookkeeping/currency-utils'
|
||||
import { applyTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import { proposalLinesToFormLines } from '@/lib/bookkeeping/proposal-lines'
|
||||
import type { ProposalLine } from '@/lib/bookkeeping/proposal-lines'
|
||||
import type { BookingTemplateLibrary, CashAccount } from '@/types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
||||
@@ -33,6 +35,14 @@ interface TransactionBookingDialogProps {
|
||||
matched?: boolean,
|
||||
) => void
|
||||
preselectedTemplate?: BookingTemplateLibrary | null
|
||||
/**
|
||||
* "Andra rader" hand-off from a proposal view (QuickReviewDialog): the
|
||||
* COMPUTED lines the user was shown, prefilled for per-line editing. Takes
|
||||
* precedence over preselectedTemplate. The settlement leg's account is
|
||||
* swapped for the transaction's resolved cash account, same as the
|
||||
* library-template path.
|
||||
*/
|
||||
proposalLines?: ProposalLine[] | null
|
||||
/** Account number (string, e.g. '5460') to prefill on the counter line:
|
||||
* set when the user picked an account from the template picker's "Konton"
|
||||
* search results. Ignored when a preselectedTemplate is present. */
|
||||
@@ -118,6 +128,7 @@ export default function TransactionBookingDialog({
|
||||
transaction,
|
||||
onBooked,
|
||||
preselectedTemplate,
|
||||
proposalLines,
|
||||
preselectedAccount,
|
||||
}: TransactionBookingDialogProps) {
|
||||
const t = useTranslations('tx_booking_dialog')
|
||||
@@ -391,12 +402,19 @@ export default function TransactionBookingDialog({
|
||||
<div className="space-y-4">
|
||||
{bankAccount !== null && (
|
||||
<JournalEntryForm
|
||||
key={`${transaction.id}-${preselectedTemplate?.id ?? 'default'}-${preselectedAccount ?? 'none'}-${bankAccount}`}
|
||||
key={`${transaction.id}-${proposalLines && proposalLines.length > 0 ? 'proposal' : preselectedTemplate?.id ?? 'default'}-${preselectedAccount ?? 'none'}-${bankAccount}`}
|
||||
embedded
|
||||
initialLines={
|
||||
preselectedTemplate
|
||||
? buildInitialLinesFromTemplate(transaction, preselectedTemplate, bankAccount)
|
||||
: buildInitialLines(transaction, bankAccountName ?? t('bank_line_description'), bankAccount, preselectedAccount)
|
||||
proposalLines && proposalLines.length > 0
|
||||
? proposalLinesToFormLines(proposalLines, {
|
||||
settlementAccount: bankAccount,
|
||||
currency: transaction.currency,
|
||||
foreignAmount: Math.abs(transaction.amount),
|
||||
exchangeRate: transaction.exchange_rate,
|
||||
})
|
||||
: preselectedTemplate
|
||||
? buildInitialLinesFromTemplate(transaction, preselectedTemplate, bankAccount)
|
||||
: buildInitialLines(transaction, bankAccountName ?? t('bank_line_description'), bankAccount, preselectedAccount)
|
||||
}
|
||||
initialDate={transaction.date}
|
||||
initialDescription={transaction.description}
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeProposalLines, proposalLinesToFormLines, resolveTemplateAccountsForEntity } from '@/lib/bookkeeping/proposal-lines'
|
||||
import type { ProposalLine } from '@/lib/bookkeeping/proposal-lines'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { LinePatternEntry } from '@/types'
|
||||
|
||||
function sumSide(lines: ProposalLine[], side: 'debet' | 'kredit'): number {
|
||||
return roundOre(lines.filter(l => l.side === side).reduce((s, l) => s + l.amount, 0))
|
||||
}
|
||||
|
||||
describe('computeProposalLines', () => {
|
||||
describe('category branch (AI suggestion / category booking)', () => {
|
||||
it('builds expense lines with extracted VAT and marks the bank leg as settlement', () => {
|
||||
const lines = computeProposalLines({
|
||||
amount: -123.45,
|
||||
category: 'expense_software',
|
||||
vatTreatment: 'standard_25',
|
||||
})
|
||||
// net 98.76 + VAT 24.69 = 123.45 (ore rounding preserved)
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '5420', amount: 98.76 },
|
||||
{ side: 'debet', account: '2641', amount: 24.69 },
|
||||
{ side: 'kredit', account: '1930', amount: 123.45, settlement: true },
|
||||
])
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('applies the account override on the non-bank side (what the AI proposal edits)', () => {
|
||||
const lines = computeProposalLines({
|
||||
amount: -100,
|
||||
category: 'expense_software',
|
||||
vatTreatment: 'standard_25',
|
||||
accountOverride: '4010',
|
||||
})
|
||||
expect(lines[0]).toEqual({ side: 'debet', account: '4010', amount: 80 })
|
||||
// The bank leg keeps the settlement flag, never the override
|
||||
expect(lines[2]).toEqual({ side: 'kredit', account: '1930', amount: 100, settlement: true })
|
||||
})
|
||||
|
||||
it('builds income lines with output VAT and settlement on the debit bank leg', () => {
|
||||
const lines = computeProposalLines({
|
||||
amount: 106,
|
||||
category: 'income_services',
|
||||
vatTreatment: 'reduced_6',
|
||||
})
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '1930', amount: 106, settlement: true },
|
||||
{ side: 'kredit', account: '2631', amount: 6 },
|
||||
{ side: 'kredit', account: '3003', amount: 100 },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the SEK-equivalent magnitude for foreign-currency transactions', () => {
|
||||
const lines = computeProposalLines({
|
||||
amount: -100, // EUR
|
||||
amountSek: 1150,
|
||||
category: 'expense_other',
|
||||
vatTreatment: 'standard_25',
|
||||
})
|
||||
const bank = lines.find(l => l.settlement)
|
||||
expect(bank).toEqual({ side: 'kredit', account: '1930', amount: 1150, settlement: true })
|
||||
expect(sumSide(lines, 'debet')).toBe(1150)
|
||||
})
|
||||
|
||||
it('adds the reverse-charge offsetting pair for category expenses', () => {
|
||||
const lines = computeProposalLines({
|
||||
amount: -1000,
|
||||
category: 'expense_other',
|
||||
vatTreatment: 'reverse_charge',
|
||||
})
|
||||
expect(lines).toContainEqual({ side: 'debet', account: '2645', amount: 250 })
|
||||
expect(lines).toContainEqual({ side: 'kredit', account: '2614', amount: 250 })
|
||||
})
|
||||
|
||||
it('returns no lines without a category', () => {
|
||||
expect(computeProposalLines({ amount: -100 })).toEqual([])
|
||||
})
|
||||
|
||||
it('balances 12% amounts that break independently-rounded net+VAT (skeptic counterexample)', () => {
|
||||
// 102.06 at 12%: rounding net and VAT separately gives 91.13 + 10.94 =
|
||||
// 102.07 (off by 1 ore). The engine computes VAT once (roundOre) and
|
||||
// derives the net by subtraction: 10.93 + 91.13 = 102.06.
|
||||
const lines = computeProposalLines({
|
||||
amount: -102.06,
|
||||
category: 'expense_representation', // maps to reduced_12 by default
|
||||
})
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '6071', amount: 91.13 },
|
||||
{ side: 'debet', account: '2641', amount: 10.93 },
|
||||
{ side: 'kredit', account: '1930', amount: 102.06, settlement: true },
|
||||
])
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it("books no VAT for an explicit 'exempt' deviation (Ingen moms)", () => {
|
||||
// The dialog resolves a user's "Ingen moms" deviation to 'exempt'
|
||||
// before computing lines; the mapping must NOT re-derive the 25%
|
||||
// category default into the prefill.
|
||||
const lines = computeProposalLines({
|
||||
amount: -1000,
|
||||
category: 'expense_other',
|
||||
vatTreatment: 'exempt',
|
||||
accountOverride: '2350',
|
||||
})
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '2350', amount: 1000 },
|
||||
{ side: 'kredit', account: '1930', amount: 1000, settlement: true },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('template branch (static review template)', () => {
|
||||
it('builds an expense from debit/credit pair with VAT rate', () => {
|
||||
const lines = computeProposalLines({
|
||||
amount: -125,
|
||||
templateDebitAccount: '6212',
|
||||
templateCreditAccount: '1930',
|
||||
templateVatRate: 0.25,
|
||||
})
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '6212', amount: 100 },
|
||||
{ side: 'debet', account: '2641', amount: 25 },
|
||||
{ side: 'kredit', account: '1930', amount: 125, settlement: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('builds an income booking with rate-mapped output VAT account', () => {
|
||||
const lines = computeProposalLines({
|
||||
amount: 112,
|
||||
templateDebitAccount: '1930',
|
||||
templateCreditAccount: '3002',
|
||||
templateVatRate: 0.12,
|
||||
})
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '1930', amount: 112, settlement: true },
|
||||
{ side: 'kredit', account: '3002', amount: 100 },
|
||||
{ side: 'kredit', account: '2621', amount: 12 },
|
||||
])
|
||||
})
|
||||
|
||||
it('builds the full reverse-charge verifikation incl. fiktiv moms and basbelopp pairs', () => {
|
||||
const lines = computeProposalLines({
|
||||
amount: -1000,
|
||||
templateDebitAccount: '6540',
|
||||
templateCreditAccount: '1930',
|
||||
templateVatRate: 0,
|
||||
templateVatTreatment: 'reverse_charge',
|
||||
templateSupplierType: 'eu_business',
|
||||
})
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '6540', amount: 1000 },
|
||||
{ side: 'kredit', account: '1930', amount: 1000, settlement: true },
|
||||
{ side: 'debet', account: '2645', amount: 250 },
|
||||
{ side: 'kredit', account: '2614', amount: 250 },
|
||||
{ side: 'debet', account: '4535', amount: 1000 },
|
||||
{ side: 'kredit', account: '4598', amount: 1000 },
|
||||
])
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('skips the basbelopp pair when the debit account is already a basis account', () => {
|
||||
const lines = computeProposalLines({
|
||||
amount: -1000,
|
||||
templateDebitAccount: '4535',
|
||||
templateCreditAccount: '1930',
|
||||
templateVatTreatment: 'reverse_charge',
|
||||
templateSupplierType: 'eu_business',
|
||||
})
|
||||
expect(lines.map(l => l.account)).toEqual(['4535', '1930', '2645', '2614'])
|
||||
})
|
||||
|
||||
it("uses the engine's plain rounding for fiktiv moms (no EPSILON nudge)", () => {
|
||||
// 8.62 * 0.25 = 2.155 stored as 2.1549999...: the engine's
|
||||
// Math.round(x*100)/100 gives 2.15; roundOre would give 2.16 and the
|
||||
// prefill would diverge from the booked verifikat by 1 ore.
|
||||
const lines = computeProposalLines({
|
||||
amount: -8.62,
|
||||
templateDebitAccount: '6540',
|
||||
templateCreditAccount: '1930',
|
||||
templateVatTreatment: 'reverse_charge',
|
||||
templateSupplierType: 'eu_business',
|
||||
})
|
||||
expect(lines.find(l => l.account === '2645')?.amount).toBe(2.15)
|
||||
expect(lines.find(l => l.account === '2614')?.amount).toBe(2.15)
|
||||
})
|
||||
|
||||
it('balances 12% template amounts via net-by-subtraction', () => {
|
||||
const lines = computeProposalLines({
|
||||
amount: -100.94,
|
||||
templateDebitAccount: '5831',
|
||||
templateCreditAccount: '1930',
|
||||
templateVatRate: 0.12,
|
||||
})
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '5831', amount: 90.12 },
|
||||
{ side: 'debet', account: '2641', amount: 10.82 },
|
||||
{ side: 'kredit', account: '1930', amount: 100.94, settlement: true },
|
||||
])
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy counterparty pair branch', () => {
|
||||
it('emits the 2645/2614 fiktiv-moms pair (no basbelopp) for a reverse-charge pair', () => {
|
||||
// Engine books D 6540 / K 1930 / D 2645 / K 2614 for a learned RC
|
||||
// counterparty (legacy path); the prefill dropping the pair would book
|
||||
// an RC expense without fiktiv moms (ruta 30/48 understated).
|
||||
const lines = computeProposalLines({
|
||||
amount: -12500,
|
||||
templateDebitAccount: '6540',
|
||||
templateCreditAccount: '1930',
|
||||
templateVatTreatment: 'reverse_charge',
|
||||
counterpartyLegacy: true,
|
||||
})
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '6540', amount: 12500 },
|
||||
{ side: 'kredit', account: '1930', amount: 12500, settlement: true },
|
||||
{ side: 'debet', account: '2645', amount: 3125 },
|
||||
{ side: 'kredit', account: '2614', amount: 3125 },
|
||||
])
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('extracts input VAT from the treatment for a normal expense pair', () => {
|
||||
const lines = computeProposalLines({
|
||||
amount: -125,
|
||||
templateDebitAccount: '6212',
|
||||
templateCreditAccount: '1930',
|
||||
templateVatTreatment: 'standard_25',
|
||||
counterpartyLegacy: true,
|
||||
})
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '6212', amount: 100 },
|
||||
{ side: 'debet', account: '2641', amount: 25 },
|
||||
{ side: 'kredit', account: '1930', amount: 125, settlement: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('books income-learned pairs gross without VAT legs (engine gates VAT on expenses)', () => {
|
||||
const lines = computeProposalLines({
|
||||
amount: 1250,
|
||||
templateDebitAccount: '1930',
|
||||
templateCreditAccount: '3001',
|
||||
templateVatTreatment: 'standard_25',
|
||||
counterpartyLegacy: true,
|
||||
})
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '1930', amount: 1250, settlement: true },
|
||||
{ side: 'kredit', account: '3001', amount: 1250 },
|
||||
])
|
||||
})
|
||||
|
||||
it('mirrors a refund against an expense-learned pair incl. the VAT leg', () => {
|
||||
// Incoming refund (amount > 0) matching an expense-learned pair:
|
||||
// engine settles debit against the bank, credits the business account
|
||||
// net and mirrors the input VAT to a 2641 credit.
|
||||
const lines = computeProposalLines({
|
||||
amount: 125,
|
||||
templateDebitAccount: '6212',
|
||||
templateCreditAccount: '1930',
|
||||
templateVatTreatment: 'standard_25',
|
||||
counterpartyLegacy: true,
|
||||
})
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '1930', amount: 125, settlement: true },
|
||||
{ side: 'kredit', account: '6212', amount: 100 },
|
||||
{ side: 'kredit', account: '2641', amount: 25 },
|
||||
])
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('mirrors an outgoing repayment against an income-learned pair gross', () => {
|
||||
const lines = computeProposalLines({
|
||||
amount: -500,
|
||||
templateDebitAccount: '1930',
|
||||
templateCreditAccount: '3001',
|
||||
templateVatTreatment: 'standard_25',
|
||||
counterpartyLegacy: true,
|
||||
})
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '3001', amount: 500 },
|
||||
{ side: 'kredit', account: '1930', amount: 500, settlement: true },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTemplateAccountsForEntity', () => {
|
||||
const template = {
|
||||
debit_account: '2013',
|
||||
credit_account: '1930',
|
||||
debit_account_ab: '2893',
|
||||
}
|
||||
|
||||
it('keeps EF accounts for enskild firma', () => {
|
||||
expect(resolveTemplateAccountsForEntity(template, 'enskild_firma')).toEqual({
|
||||
debitAccount: '2013',
|
||||
creditAccount: '1930',
|
||||
})
|
||||
})
|
||||
|
||||
it('substitutes AB accounts for aktiebolag, falling back per side', () => {
|
||||
expect(resolveTemplateAccountsForEntity(template, 'aktiebolag')).toEqual({
|
||||
debitAccount: '2893',
|
||||
creditAccount: '1930',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('line pattern branch (counterparty template)', () => {
|
||||
const pattern: LinePatternEntry[] = [
|
||||
{ account: '2641', type: 'vat', side: 'debit', vat_rate: 0.25 },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio: 1 },
|
||||
]
|
||||
|
||||
it('builds settlement + VAT + business lines from the pattern', () => {
|
||||
const lines = computeProposalLines({ amount: -1000, linePattern: pattern })
|
||||
expect(lines).toEqual([
|
||||
{ side: 'kredit', account: '1930', amount: 1000, settlement: true },
|
||||
{ side: 'debet', account: '2641', amount: 200 },
|
||||
{ side: 'debet', account: '6212', amount: 800 },
|
||||
])
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('respects a custom settlement account', () => {
|
||||
const lines = computeProposalLines({ amount: -1000, linePattern: pattern, settlementAccount: '1932' })
|
||||
expect(lines[0]).toEqual({ side: 'kredit', account: '1932', amount: 1000, settlement: true })
|
||||
})
|
||||
|
||||
it("books an expense settlement on the template's learned credit account (engine parity)", () => {
|
||||
// SIE-learned pattern settling on leverantorsskulder: the engine books
|
||||
// the money leg on tmpl.credit_account (buildTransactionEntryLines),
|
||||
// and applySettlementAccount never rewrites a non-1930 leg. The prefill
|
||||
// must show 2440, not a default 1930.
|
||||
const lines = computeProposalLines({
|
||||
amount: -1250,
|
||||
linePattern: pattern,
|
||||
templateDebitAccount: '4010',
|
||||
templateCreditAccount: '2440',
|
||||
})
|
||||
expect(lines[0]).toEqual({ side: 'kredit', account: '2440', amount: 1250, settlement: true })
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it("books an income settlement on the template's learned debit account (engine parity)", () => {
|
||||
const incomePattern: LinePatternEntry[] = [
|
||||
{ account: '2611', type: 'vat', side: 'credit', vat_rate: 0.25 },
|
||||
{ account: '3001', type: 'business', side: 'credit', ratio: 1 },
|
||||
]
|
||||
const lines = computeProposalLines({
|
||||
amount: 1250,
|
||||
linePattern: incomePattern,
|
||||
templateDebitAccount: '1510',
|
||||
templateCreditAccount: '3001',
|
||||
})
|
||||
expect(lines[0]).toEqual({ side: 'debet', account: '1510', amount: 1250, settlement: true })
|
||||
})
|
||||
|
||||
it('mirror-swaps the learned pair for a refund settlement (engine parity)', () => {
|
||||
// Refund (amount > 0) of an expense-learned pattern: the engine's
|
||||
// mirror swaps the legacy pair, so result.debit_account is
|
||||
// tmpl.credit_account and the money leg lands there.
|
||||
const lines = computeProposalLines({
|
||||
amount: 1000,
|
||||
linePattern: pattern,
|
||||
templateDebitAccount: '4010',
|
||||
templateCreditAccount: '2440',
|
||||
})
|
||||
expect(lines[0]).toEqual({ side: 'debet', account: '2440', amount: 1000, settlement: true })
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('falls back to the swappable 1930 default when the learned pair is absent', () => {
|
||||
const lines = computeProposalLines({ amount: -1000, linePattern: pattern })
|
||||
expect(lines[0]).toEqual({ side: 'kredit', account: '1930', amount: 1000, settlement: true })
|
||||
})
|
||||
|
||||
it('books the ore rounding difference on 3740', () => {
|
||||
const multi: LinePatternEntry[] = [
|
||||
{ account: '6110', type: 'business', side: 'debit', ratio: 0.333 },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio: 0.333 },
|
||||
{ account: '6991', type: 'business', side: 'debit', ratio: 0.333 },
|
||||
]
|
||||
const lines = computeProposalLines({ amount: -100, linePattern: multi })
|
||||
// 3 x 33.30 = 99.90, diff 0.10 lands on 3740 on the business side
|
||||
expect(lines).toContainEqual({ side: 'debet', account: '3740', amount: 0.1 })
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('handles income patterns with the settlement on the debit side', () => {
|
||||
const incomePattern: LinePatternEntry[] = [
|
||||
{ account: '2611', type: 'vat', side: 'credit', vat_rate: 0.25 },
|
||||
{ account: '3001', type: 'business', side: 'credit', ratio: 1 },
|
||||
]
|
||||
const lines = computeProposalLines({ amount: 1250, linePattern: incomePattern })
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '1930', amount: 1250, settlement: true },
|
||||
{ side: 'kredit', account: '2611', amount: 250 },
|
||||
{ side: 'kredit', account: '3001', amount: 1000 },
|
||||
])
|
||||
})
|
||||
|
||||
it('mirrors a sign-mismatched pattern like the engine (refund of an expense pattern)', () => {
|
||||
// Refund (amount > 0) hitting an expense-learned pattern: the engine
|
||||
// flips every learned side so the mirrored entry reduces what the
|
||||
// pattern built up, instead of debiting expense accounts for money in.
|
||||
const lines = computeProposalLines({ amount: 1000, linePattern: pattern })
|
||||
expect(lines).toEqual([
|
||||
{ side: 'debet', account: '1930', amount: 1000, settlement: true },
|
||||
{ side: 'kredit', account: '2641', amount: 200 },
|
||||
{ side: 'kredit', account: '6212', amount: 800 },
|
||||
])
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('ignores ratio on vat-type entries when allocating (engine filters by type)', () => {
|
||||
const mixed: LinePatternEntry[] = [
|
||||
{ account: '2641', type: 'vat', side: 'debit', vat_rate: 0.25, ratio: 0.5 },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio: 1 },
|
||||
]
|
||||
const lines = computeProposalLines({ amount: -1000, linePattern: mixed })
|
||||
// The vat entry's stray ratio must not allocate a second business leg.
|
||||
expect(lines.map(l => l.account)).toEqual(['1930', '2641', '6212'])
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('proposalLinesToFormLines', () => {
|
||||
const lines: ProposalLine[] = [
|
||||
{ side: 'debet', account: '5420', amount: 98.76 },
|
||||
{ side: 'debet', account: '2641', amount: 24.69 },
|
||||
{ side: 'kredit', account: '1930', amount: 123.45, settlement: true },
|
||||
]
|
||||
|
||||
it('maps sides to debit/credit strings with two-decimal formatting', () => {
|
||||
const formLines = proposalLinesToFormLines(lines)
|
||||
expect(formLines).toEqual([
|
||||
{ account_number: '5420', debit_amount: '98.76', credit_amount: '', line_description: '' },
|
||||
{ account_number: '2641', debit_amount: '24.69', credit_amount: '', line_description: '' },
|
||||
{ account_number: '1930', debit_amount: '', credit_amount: '123.45', line_description: '' },
|
||||
])
|
||||
})
|
||||
|
||||
it('formats whole amounts with trailing zeros', () => {
|
||||
const formLines = proposalLinesToFormLines([{ side: 'debet', account: '6212', amount: 100 }])
|
||||
expect(formLines[0].debit_amount).toBe('100.00')
|
||||
})
|
||||
|
||||
it('swaps a literal-1930 settlement leg to the resolved cash account', () => {
|
||||
const formLines = proposalLinesToFormLines(lines, { settlementAccount: '1932' })
|
||||
expect(formLines[2].account_number).toBe('1932')
|
||||
// Non-settlement legs are never swapped
|
||||
expect(formLines[0].account_number).toBe('5420')
|
||||
})
|
||||
|
||||
it('never rewrites a learned non-1930 settlement leg (applySettlementAccount parity)', () => {
|
||||
// A legacy counterparty template can settle against 2440 (payables):
|
||||
// the engine's applySettlementAccount substitutes only the literal 1930
|
||||
// default, so the prefill must keep the learned account too.
|
||||
const learned: ProposalLine[] = [
|
||||
{ side: 'debet', account: '6212', amount: 100 },
|
||||
{ side: 'kredit', account: '2440', amount: 100, settlement: true },
|
||||
]
|
||||
const formLines = proposalLinesToFormLines(learned, { settlementAccount: '1932' })
|
||||
expect(formLines[1].account_number).toBe('2440')
|
||||
})
|
||||
|
||||
it('stamps currency metadata on the settlement leg only', () => {
|
||||
const formLines = proposalLinesToFormLines(lines, {
|
||||
settlementAccount: '1930',
|
||||
currency: 'EUR',
|
||||
foreignAmount: 10.5,
|
||||
exchangeRate: 11.7571,
|
||||
})
|
||||
expect(formLines[2]).toMatchObject({
|
||||
account_number: '1930',
|
||||
currency: 'EUR',
|
||||
amount_in_currency: 10.5,
|
||||
exchange_rate: 11.7571,
|
||||
})
|
||||
expect(formLines[0]).not.toHaveProperty('currency')
|
||||
expect(formLines[1]).not.toHaveProperty('currency')
|
||||
})
|
||||
|
||||
it('adds no currency metadata for SEK transactions', () => {
|
||||
const formLines = proposalLinesToFormLines(lines, { settlementAccount: '1930', currency: 'SEK' })
|
||||
expect(formLines[2]).not.toHaveProperty('currency')
|
||||
expect(formLines[2]).not.toHaveProperty('amount_in_currency')
|
||||
})
|
||||
|
||||
it('round-trips a computed proposal into balanced form lines', () => {
|
||||
const computed = computeProposalLines({
|
||||
amount: -123.45,
|
||||
category: 'expense_software',
|
||||
vatTreatment: 'standard_25',
|
||||
})
|
||||
const formLines = proposalLinesToFormLines(computed, { settlementAccount: '1932' })
|
||||
const debits = formLines.reduce((s, l) => s + (l.debit_amount ? Number(l.debit_amount) : 0), 0)
|
||||
const credits = formLines.reduce((s, l) => s + (l.credit_amount ? Number(l.credit_amount) : 0), 0)
|
||||
expect(roundOre(debits)).toBe(roundOre(credits))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* 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 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 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
|
||||
}
|
||||
|
||||
type LearnedDirection = 'expense' | 'income' | 'unknown'
|
||||
|
||||
/**
|
||||
* Settlement-account predicate, mirroring the private isSettlementAccount in
|
||||
* counterparty-templates.ts (bank/cash 19xx, receivables 1510, payables 2440,
|
||||
* credit card 2890). Keep the two in sync.
|
||||
*/
|
||||
function isSettlementAccount(account: string): boolean {
|
||||
return account.startsWith('19') || account === '1510' || account === '2440' || account === '2890'
|
||||
}
|
||||
|
||||
/** Mirrors legacyTemplateDirection in counterparty-templates.ts. */
|
||||
function legacyDirection(debitAccount: string, creditAccount: string): LearnedDirection {
|
||||
const debitSettles = isSettlementAccount(debitAccount)
|
||||
const creditSettles = isSettlementAccount(creditAccount)
|
||||
if (creditSettles && !debitSettles) return 'expense'
|
||||
if (debitSettles && !creditSettles) return 'income'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** Mirrors patternDirection in counterparty-templates.ts. */
|
||||
function patternDirection(pattern: LinePatternEntry[]): LearnedDirection {
|
||||
const business = pattern.filter((e) => e.type === 'business')
|
||||
if (business.length === 0) return 'unknown'
|
||||
const debitCount = business.filter((b) => b.side === 'debit').length
|
||||
if (debitCount === business.length) return 'expense'
|
||||
if (debitCount === 0) return 'income'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const businessSide = linePattern.find(e => e.type === 'business')?.side ?? 'credit'
|
||||
result.push({ side: side(businessSide), 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 : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -29,6 +29,10 @@ export interface ReviewTemplate {
|
||||
name_sv: string
|
||||
debit_account?: string
|
||||
credit_account?: string
|
||||
/** AB-specific account overrides (static catalog templates carry these;
|
||||
* the booking engine substitutes them for aktiebolag). */
|
||||
debit_account_ab?: string
|
||||
credit_account_ab?: string
|
||||
vat_treatment?: VatTreatment | null
|
||||
vat_rate?: number
|
||||
special_rules_sv?: string
|
||||
|
||||
@@ -2978,6 +2978,7 @@
|
||||
"label_template": "Template",
|
||||
"label_category": "Category",
|
||||
"change_template": "Change template",
|
||||
"edit_lines": "Edit lines",
|
||||
"reverse_charge_warning": "Reverse charge requires the supplier's VAT registration number and country.",
|
||||
"label_account": "Account",
|
||||
"label_dimensions": "Dimensions",
|
||||
|
||||
@@ -2978,6 +2978,7 @@
|
||||
"label_template": "Mall",
|
||||
"label_category": "Kategori",
|
||||
"change_template": "Byt mall",
|
||||
"edit_lines": "Ändra rader",
|
||||
"reverse_charge_warning": "Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.",
|
||||
"label_account": "Konto",
|
||||
"label_dimensions": "Dimensioner",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
]
|
||||
},
|
||||
"naiveOreRound": {
|
||||
"count": 626
|
||||
"count": 622
|
||||
},
|
||||
"handRolledInvariants": {
|
||||
"count": 115
|
||||
|
||||
Reference in New Issue
Block a user