diff --git a/components/extensions/general/EditKonteringDialog.tsx b/components/extensions/general/EditKonteringDialog.tsx index f583215b..a0c45658 100644 --- a/components/extensions/general/EditKonteringDialog.tsx +++ b/components/extensions/general/EditKonteringDialog.tsx @@ -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({ Ändra kontering + {matchedTransaction && ( +

+ {t('dialog_matched_transaction')}: {formatCurrency(matchedTransaction.amount_sek)} ·{' '} + {formatDate(matchedTransaction.date)} +

+ )}
diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index fa60cb9c..7498f52a 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -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 = { @@ -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 diff --git a/extensions/general/invoice-inbox/__tests__/fallback-kontering.test.ts b/extensions/general/invoice-inbox/__tests__/fallback-kontering.test.ts new file mode 100644 index 00000000..710e34a0 --- /dev/null +++ b/extensions/general/invoice-inbox/__tests__/fallback-kontering.test.ts @@ -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') + }) +}) diff --git a/extensions/general/invoice-inbox/__tests__/suggest-booking.test.ts b/extensions/general/invoice-inbox/__tests__/suggest-booking.test.ts index 9e1e1cb3..04683339 100644 --- a/extensions/general/invoice-inbox/__tests__/suggest-booking.test.ts +++ b/extensions/general/invoice-inbox/__tests__/suggest-booking.test.ts @@ -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 () => { diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index 883ec23b..9b69c1dc 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -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'), + }, }) } }, diff --git a/extensions/general/invoice-inbox/lib/fallback-kontering.ts b/extensions/general/invoice-inbox/lib/fallback-kontering.ts new file mode 100644 index 00000000..028fa37f --- /dev/null +++ b/extensions/general/invoice-inbox/lib/fallback-kontering.ts @@ -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] +} diff --git a/messages/en.json b/messages/en.json index 2ef2981a..5ca8f297 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 7f95bc59..c8b96a72 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",