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:
co-authored by
Claude Fable 5
parent
1f9578ca76
commit
0bb482bf6e
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user