fix(inbox): carry the matched transaction amount into manual booking (#1546)
PR #1524 swapped the matched-state "Bokfor manuellt" path from BookDirectlyDialog to EditKonteringDialog, which is seeded only from the booking proposal. An unknown supplier has no proposal, so the dialog opened with two blank rows and no amount at all: on a foreign-currency invoice the SEK figure then existed nowhere on screen (user-reported regression, 2026-08-12). suggest-booking now returns, on every empty-proposal branch (no_mapping, currency_unsupported, engine failure), the matched bank row's SEK amount and date plus a balanced two-row skeleton: the settlement account on one side, a blank cost row on the other, mirroring what buildPrefillLines seeded before the swap. The SEK amount goes through resolveSekAmountOrNull, so a foreign row with no honest kronor figure still opens blank rather than relabeling EUR as SEK. The dialog also shows the matched transaction's amount and date beside the title, and empty proposals now carry the bank date so the entry no longer falls back to the document date. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
* room for line text, dimensions or tax codes.
|
||||
*/
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
|
||||
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ export default function EditKonteringDialog({
|
||||
entryDate,
|
||||
description,
|
||||
lines,
|
||||
matchedTransaction = null,
|
||||
onBooked,
|
||||
}: {
|
||||
open: boolean
|
||||
@@ -64,6 +66,11 @@ export default function EditKonteringDialog({
|
||||
entryDate: string
|
||||
description: string
|
||||
lines: ProposedLine[]
|
||||
/** SEK amount and date of the matched bank row, when there is one. Shown
|
||||
beside the title so the kronor figure stays visible even if the user
|
||||
clears the rows: on a foreign-currency invoice this is the only place
|
||||
the SEK amount exists at all. */
|
||||
matchedTransaction?: { amount_sek: number; date: string } | null
|
||||
onBooked: (entryId: string) => void
|
||||
}) {
|
||||
const t = useTranslations('inbox_workspace')
|
||||
@@ -73,6 +80,12 @@ export default function EditKonteringDialog({
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ändra kontering</DialogTitle>
|
||||
{matchedTransaction && (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{t('dialog_matched_transaction')}: {formatCurrency(matchedTransaction.amount_sek)} ·{' '}
|
||||
{formatDate(matchedTransaction.date)}
|
||||
</p>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,480px)]">
|
||||
|
||||
@@ -2523,6 +2523,13 @@ type SuggestedBooking = {
|
||||
description?: string
|
||||
rule_name?: string | null
|
||||
entry_date?: string
|
||||
/** Skeleton rows seeded from the matched bank transaction when `lines` is
|
||||
empty: the amount in SEK against the settlement account, cost side left
|
||||
blank. Editor prefill only; never rendered as a proposal. */
|
||||
fallback_lines?: { account_number: string; debit_amount: number; credit_amount: number; description: string }[]
|
||||
/** The matched bank row's SEK amount and date, present on empty proposals
|
||||
so the dialog can still show the kronor figure. */
|
||||
transaction?: { amount_sek: number; date: string } | null
|
||||
}
|
||||
|
||||
const SUGGESTION_SOURCE_LABEL: Record<string, string> = {
|
||||
@@ -3231,7 +3238,12 @@ function FieldsRail({
|
||||
new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
description={data?.supplier?.name ?? item.email_subject ?? 'Underlag'}
|
||||
lines={proposal?.lines ?? []}
|
||||
// No proposal is not the same as no amount: the matched bank row still
|
||||
// knows what left the account and where. The fallback skeleton keeps
|
||||
// the kronor figure in the form (regression report 2026-08-12: match,
|
||||
// "Bokför manuellt", and the amount no longer followed along).
|
||||
lines={proposal?.lines.length ? proposal.lines : (proposal?.fallback_lines ?? [])}
|
||||
matchedTransaction={proposal?.transaction ?? null}
|
||||
onBooked={() => {
|
||||
setEditOpen(false)
|
||||
// Realtime refreshes the list, but this rail renders from the
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The skeleton the manual-booking dialog opens with when the engine has no
|
||||
* proposal. Two things matter: the kronor figure comes from the bank row (via
|
||||
* the honest SEK ladder, never the raw foreign amount), and the two rows
|
||||
* always balance, so the form's balance check starts green.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildFallbackKonteringLines } from '@/extensions/general/invoice-inbox/lib/fallback-kontering'
|
||||
|
||||
describe('buildFallbackKonteringLines', () => {
|
||||
it('seeds a purchase as blank-cost debit against a settlement credit', () => {
|
||||
const lines = buildFallbackKonteringLines(
|
||||
{ amount: -216.39, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||||
'1930',
|
||||
)
|
||||
expect(lines).toEqual([
|
||||
{ account_number: '', debit_amount: 216.39, credit_amount: 0, description: '' },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 216.39, description: '' },
|
||||
])
|
||||
})
|
||||
|
||||
it('reverses the legs when money came in', () => {
|
||||
const lines = buildFallbackKonteringLines(
|
||||
{ amount: 500, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||||
'1930',
|
||||
)
|
||||
expect(lines).toEqual([
|
||||
{ account_number: '1930', debit_amount: 500, credit_amount: 0, description: '' },
|
||||
{ account_number: '', debit_amount: 0, credit_amount: 500, description: '' },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the stored SEK amount for a foreign row, not the foreign figure', () => {
|
||||
// The user-reported case: a EUR invoice whose only kronor figure is the
|
||||
// bank movement. 6.25 EUR must not be prefilled as 6,25 kr.
|
||||
const lines = buildFallbackKonteringLines(
|
||||
{ amount: -6.25, amount_sek: -71.83, currency: 'EUR', exchange_rate: 11.4928 },
|
||||
'1930',
|
||||
)
|
||||
expect(lines[0].debit_amount).toBe(71.83)
|
||||
expect(lines[1].credit_amount).toBe(71.83)
|
||||
})
|
||||
|
||||
it('returns nothing for a foreign row with no SEK value and no rate', () => {
|
||||
// Relabeling 100 EUR as 100 kr is worse than an empty form.
|
||||
expect(
|
||||
buildFallbackKonteringLines(
|
||||
{ amount: -100, amount_sek: null, currency: 'EUR', exchange_rate: null },
|
||||
'1930',
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('returns nothing for a zero amount', () => {
|
||||
expect(
|
||||
buildFallbackKonteringLines(
|
||||
{ amount: 0, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||||
'1930',
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('rounds to whole öre and stays balanced', () => {
|
||||
const lines = buildFallbackKonteringLines(
|
||||
{ amount: -10, amount_sek: null, currency: 'USD', exchange_rate: 9.4567 },
|
||||
'1932',
|
||||
)
|
||||
const debit = lines.reduce((t, l) => t + l.debit_amount, 0)
|
||||
const credit = lines.reduce((t, l) => t + l.credit_amount, 0)
|
||||
expect(debit).toBe(94.57)
|
||||
expect(Math.round((debit - credit) * 100)).toBe(0)
|
||||
expect(lines[1].account_number).toBe('1932')
|
||||
})
|
||||
})
|
||||
@@ -212,14 +212,66 @@ describe('POST /items/:id/suggest-booking', () => {
|
||||
expect(body.data.lines).toEqual([])
|
||||
})
|
||||
|
||||
it('still hands the dialog the bank amount when there is no proposal', async () => {
|
||||
// The 2026-08-12 regression: unknown supplier meant an empty proposal,
|
||||
// and the manual-booking dialog opened with no amount at all. An empty
|
||||
// proposal must still carry the matched row's kronor figure, its date,
|
||||
// and a balanced two-row skeleton against the settlement account.
|
||||
evaluateMappingRules.mockResolvedValue(mappingResult({ debit_account: '', credit_account: '' }))
|
||||
const mock = createQueuedMockSupabase()
|
||||
queueRows(mock)
|
||||
const { body } = await parseJsonResponse<{
|
||||
data: {
|
||||
entry_date: string
|
||||
transaction: { amount_sek: number; date: string }
|
||||
fallback_lines: { account_number: string; debit_amount: number; credit_amount: number }[]
|
||||
}
|
||||
}>(await route.handler(req(), buildCtx(mock.supabase)))
|
||||
|
||||
expect(body.data.entry_date).toBe('2026-08-04')
|
||||
expect(body.data.transaction).toEqual({ amount_sek: -21639, date: '2026-08-04' })
|
||||
expect(body.data.fallback_lines).toEqual([
|
||||
{ account_number: '', debit_amount: 21639, credit_amount: 0, description: '' },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 21639, description: '' },
|
||||
])
|
||||
})
|
||||
|
||||
it('builds the skeleton from the SEK amount on a withheld foreign proposal', async () => {
|
||||
// currency_unsupported hides the rule's wrong VAT, but the bank row's SEK
|
||||
// amount is still the one honest kronor figure and must reach the dialog.
|
||||
evaluateMappingRules.mockResolvedValue(mappingResult({ rule: { rule_name: 'ACME' }, template_id: undefined }))
|
||||
const mock = createQueuedMockSupabase()
|
||||
queueRows(mock, { tx: { amount: -100, amount_sek: -1150, currency: 'EUR', exchange_rate: 11.5 } })
|
||||
const { body } = await parseJsonResponse<{
|
||||
data: {
|
||||
source: string
|
||||
transaction: { amount_sek: number }
|
||||
fallback_lines: { debit_amount: number; credit_amount: number }[]
|
||||
}
|
||||
}>(await route.handler(req(), buildCtx(mock.supabase)))
|
||||
|
||||
expect(body.data.source).toBe('currency_unsupported')
|
||||
expect(body.data.transaction.amount_sek).toBe(-1150)
|
||||
expect(body.data.fallback_lines[0].debit_amount).toBe(1150)
|
||||
expect(body.data.fallback_lines[1].credit_amount).toBe(1150)
|
||||
})
|
||||
|
||||
it('degrades rather than 500s when the mapping engine throws', async () => {
|
||||
evaluateMappingRules.mockRejectedValue(new Error('boom'))
|
||||
const mock = createQueuedMockSupabase()
|
||||
queueRows(mock)
|
||||
const res = await route.handler(req(), buildCtx(mock.supabase))
|
||||
expect(res.status).toBe(200)
|
||||
const { body } = await parseJsonResponse<{ data: { source: string } }>(res)
|
||||
const { body } = await parseJsonResponse<{
|
||||
data: { source: string; fallback_lines: { account_number: string; credit_amount: number }[] }
|
||||
}>(res)
|
||||
expect(body.data.source).toBe('no_mapping')
|
||||
// Even here the dialog gets the amount: the settlement resolution may be
|
||||
// what threw, so the skeleton falls back to the 1930 default.
|
||||
expect(body.data.fallback_lines).toEqual([
|
||||
{ account_number: '', debit_amount: 21639, credit_amount: 0, description: '' },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 21639, description: '' },
|
||||
])
|
||||
})
|
||||
|
||||
it('proposes nothing when the bank line already has a verifikat', async () => {
|
||||
|
||||
@@ -60,6 +60,8 @@ import { bulkBookMatchedInboxItems } from '@/lib/transactions/categorize-core'
|
||||
import { hasCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { resolveSekAmountOrNull } from '@/lib/bookkeeping/currency-utils'
|
||||
import { buildFallbackKonteringLines } from './lib/fallback-kontering'
|
||||
import { buildTransactionEntryLines } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
@@ -2471,6 +2473,30 @@ export const invoiceInboxExtension: Extension = {
|
||||
})
|
||||
}
|
||||
|
||||
// Everything an empty proposal can still say about the matched bank
|
||||
// row: the amount in kronor and the day the money moved. Without it
|
||||
// the manual-booking dialog opened with nothing at all (the regression
|
||||
// behind "beloppet följer inte med längre"), which on a foreign
|
||||
// invoice left the user with no kronor figure anywhere.
|
||||
const txSekSigned = resolveSekAmountOrNull(
|
||||
(tx as Transaction).amount,
|
||||
(tx as Transaction).amount_sek,
|
||||
(tx as Transaction).currency,
|
||||
(tx as Transaction).exchange_rate,
|
||||
)
|
||||
const txSummary =
|
||||
txSekSigned != null
|
||||
? {
|
||||
amount_sek: roundOre(txSekSigned),
|
||||
date: (tx as Transaction).date,
|
||||
}
|
||||
: null
|
||||
const emptyProposalExtras = (settlementAccount: string) => ({
|
||||
entry_date: (tx as Transaction).date,
|
||||
transaction: txSummary,
|
||||
fallback_lines: buildFallbackKonteringLines(tx as Transaction, settlementAccount),
|
||||
})
|
||||
|
||||
try {
|
||||
const { data: settings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
@@ -2507,7 +2533,12 @@ export const invoiceInboxExtension: Extension = {
|
||||
!mapping.rule && !mapping.template_id && mapping.confidence <= 0.1
|
||||
if (isPlaceholder || !mapping.debit_account || !mapping.credit_account) {
|
||||
return NextResponse.json({
|
||||
data: { source: 'no_mapping' as const, lines: [], confidence: mapping.confidence ?? null },
|
||||
data: {
|
||||
source: 'no_mapping' as const,
|
||||
lines: [],
|
||||
confidence: mapping.confidence ?? null,
|
||||
...emptyProposalExtras(settlementAccount),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2529,7 +2560,12 @@ export const invoiceInboxExtension: Extension = {
|
||||
currency: (tx as Transaction).currency,
|
||||
})
|
||||
return NextResponse.json({
|
||||
data: { source: 'currency_unsupported' as const, lines: [], confidence: null },
|
||||
data: {
|
||||
source: 'currency_unsupported' as const,
|
||||
lines: [],
|
||||
confidence: null,
|
||||
...emptyProposalExtras(settlementAccount),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2574,9 +2610,15 @@ export const invoiceInboxExtension: Extension = {
|
||||
})
|
||||
// A suggestion that cannot be produced is not an error the user did
|
||||
// anything about: fall back to the empty proposal and let them book
|
||||
// by hand.
|
||||
// by hand. The settlement account may be what threw, so the skeleton
|
||||
// uses the 1930 default rather than the resolved account here.
|
||||
return NextResponse.json({
|
||||
data: { source: 'no_mapping' as const, lines: [], confidence: null },
|
||||
data: {
|
||||
source: 'no_mapping' as const,
|
||||
lines: [],
|
||||
confidence: null,
|
||||
...emptyProposalExtras('1930'),
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Skeleton kontering for a matched transaction the engine has no proposal for.
|
||||
*
|
||||
* When suggest-booking comes back empty (unknown supplier, withheld
|
||||
* foreign-currency rule, engine failure) the manual-booking dialog used to
|
||||
* open with nothing at all, even though the matched bank row already tells us
|
||||
* the amount in kronor and the settlement account. This rebuilds what
|
||||
* BookDirectlyDialog's buildPrefillLines seeded before the dialog swap:
|
||||
* the transaction's SEK amount against the settlement account, with the
|
||||
* counter-account left blank for the user to pick. No VAT split: with a
|
||||
* matched transaction the old prefill skipped document VAT too, since the
|
||||
* document total and the bank movement are not guaranteed to agree.
|
||||
*
|
||||
* The SEK amount goes through resolveSekAmountOrNull: a foreign row with
|
||||
* neither a stored SEK value nor a rate has no honest kronor figure, and
|
||||
* prefilling the raw foreign number would relabel 100 EUR as 100 kr. In that
|
||||
* case we return no lines and the dialog opens blank, as it does today.
|
||||
*/
|
||||
import { resolveSekAmountOrNull } from '@/lib/bookkeeping/currency-utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
|
||||
export interface FallbackKonteringTx {
|
||||
amount: number
|
||||
amount_sek?: number | null
|
||||
currency?: string | null
|
||||
exchange_rate?: number | null
|
||||
}
|
||||
|
||||
export interface FallbackKonteringLine {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export function buildFallbackKonteringLines(
|
||||
tx: FallbackKonteringTx,
|
||||
settlementAccount: string,
|
||||
): FallbackKonteringLine[] {
|
||||
const sek = resolveSekAmountOrNull(tx.amount, tx.amount_sek, tx.currency, tx.exchange_rate)
|
||||
if (sek == null) return []
|
||||
|
||||
const total = roundOre(Math.abs(sek))
|
||||
if (total <= 0) return []
|
||||
|
||||
const costLine: FallbackKonteringLine = {
|
||||
account_number: '',
|
||||
debit_amount: 0,
|
||||
credit_amount: 0,
|
||||
description: '',
|
||||
}
|
||||
const settlementLine: FallbackKonteringLine = {
|
||||
account_number: settlementAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: 0,
|
||||
description: '',
|
||||
}
|
||||
|
||||
if (sek < 0) {
|
||||
// Money left the account: debit the (unknown) cost side, credit the bank.
|
||||
costLine.debit_amount = total
|
||||
settlementLine.credit_amount = total
|
||||
return [costLine, settlementLine]
|
||||
}
|
||||
// Money came in (refund, credit note payout): debit the bank instead.
|
||||
settlementLine.debit_amount = total
|
||||
costLine.credit_amount = total
|
||||
return [settlementLine, costLine]
|
||||
}
|
||||
@@ -3065,6 +3065,7 @@
|
||||
"hunt_reading": "Reading {mailboxes}",
|
||||
"hunt_progress": "pass {pass} · {found} fetched",
|
||||
"dialog_no_document": "No document",
|
||||
"dialog_matched_transaction": "Matched transaction",
|
||||
"sources_one": "{count} source",
|
||||
"sources_many": "{count} sources",
|
||||
"source_needs_reconnect": "{address} needs reconnecting",
|
||||
|
||||
@@ -3065,6 +3065,7 @@
|
||||
"hunt_reading": "Läser {mailboxes}",
|
||||
"hunt_progress": "omgång {pass} · {found} hämtade",
|
||||
"dialog_no_document": "Ingen handling",
|
||||
"dialog_matched_transaction": "Matchad transaktion",
|
||||
"sources_one": "{count} källa",
|
||||
"sources_many": "{count} källor",
|
||||
"source_needs_reconnect": "{address} behöver återanslutas",
|
||||
|
||||
Reference in New Issue
Block a user