diff --git a/DECISIONS.md b/DECISIONS.md index 6604424b..d854befa 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1212,6 +1212,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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] : `. 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). diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 7dcdac9b..64d50954 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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(null) const [bookingDialogTemplate, setBookingDialogTemplate] = useState(null) + // "Andra rader" hand-off: the computed proposal lines from QuickReviewDialog, + // prefilled into TransactionBookingDialog for per-line editing. + const [bookingDialogProposalLines, setBookingDialogProposalLines] = useState(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(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} /> )} diff --git a/components/transactions/JournalEntryPreview.tsx b/components/transactions/JournalEntryPreview.tsx index ea79b768..b07b83ff 100644 --- a/components/transactions/JournalEntryPreview.tsx +++ b/components/transactions/JournalEntryPreview.tsx @@ -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 diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index 204e1ec6..4c8e36f0 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -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 ) => Promise 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 && (

- D: {formatAccountWithName(template.debit_account)} → K: {formatAccountWithName(template.credit_account)} + D: {formatAccountWithName(entityAccounts.debitAccount)} → K: {formatAccountWithName(entityAccounts.creditAccount)}

)} @@ -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 && ( - + + {/* "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 && ( +
+ +
)} - /> + )} {/* Account & VAT: hidden for template bookings (accounts defined by the template) */} diff --git a/components/transactions/TransactionBookingDialog.tsx b/components/transactions/TransactionBookingDialog.tsx index f696a88c..b16d8e85 100644 --- a/components/transactions/TransactionBookingDialog.tsx +++ b/components/transactions/TransactionBookingDialog.tsx @@ -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({
{bankAccount !== null && ( 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} diff --git a/lib/bookkeeping/__tests__/proposal-lines.test.ts b/lib/bookkeeping/__tests__/proposal-lines.test.ts new file mode 100644 index 00000000..daeef8fb --- /dev/null +++ b/lib/bookkeeping/__tests__/proposal-lines.test.ts @@ -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)) + }) +}) diff --git a/lib/bookkeeping/proposal-lines.ts b/lib/bookkeeping/proposal-lines.ts new file mode 100644 index 00000000..d920dcfc --- /dev/null +++ b/lib/bookkeeping/proposal-lines.ts @@ -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 : {}), + } + }) +} diff --git a/lib/transactions/quick-review-defaults.ts b/lib/transactions/quick-review-defaults.ts index 2cfcc06e..d509d108 100644 --- a/lib/transactions/quick-review-defaults.ts +++ b/lib/transactions/quick-review-defaults.ts @@ -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 diff --git a/messages/en.json b/messages/en.json index 4bc5e717..41fc8223 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index e8702b35..ce3fdd2c 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index 49e02243..86f00206 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -7,7 +7,7 @@ ] }, "naiveOreRound": { - "count": 626 + "count": 622 }, "handRolledInvariants": { "count": 115