From ebbdf96b7493a5488256b891e203403301cba83e Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 25 Aug 2026 09:23:14 +0200 Subject: [PATCH] feat(reconciliation): manual adapter for the whole balance sheet (Reko bilagor, PR 1) (#1854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(reconciliation): manual adapter so the whole balance sheet is reconcilable and signable (Reko bilagor, PR 1) Every class 1-2 account the bank and skattekonto adapters do not own now appears on the Avstämning page under "Övriga balanskonton" with IB, movement and UB through the balansdag, a system specification where one exists (1510 kundreskontra, 2440 leverantörsreskontra, 2920/2940 semesterlöneskuld) and, for every other account, the balance the signer states from their underlag at sign-off. Same three doors as before: dashboard routes, v1 API and the MCP tools take manual: keys and an external_balance. The ledger side is computed per fiscal period via generateTrialBalance, never as an all-history sum: year-end re-books every balance account in an opening_balance verifikat, so an all-history sum counts a closed year twice. A stated external_balance is refused (EXTERNAL_BALANCE_NOT_ALLOWED) wherever the system already has an outside truth, so it can never hide a difference. No migration: account_reconciliations already accepts manual:NNNN keys. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * chore(api-skill): regenerate banking reference for the sign-off external_balance field Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../accounts/[accountKey]/signoff/route.ts | 9 +- .../accounts/__tests__/signoff-route.test.ts | 30 +- .../accounts/[accountKey]/signoff/route.ts | 12 +- .../accounts/__tests__/signoff-route.test.ts | 2 +- components/reconciliation/AccountOverview.tsx | 68 ++- .../reconciliation/ReconciliationRail.tsx | 150 ++++--- components/reconciliation/SignoffDialog.tsx | 72 ++- .../__tests__/reconciliation-tools.test.ts | 2 +- extensions/general/mcp-server/server.ts | 17 +- lib/pending-operations/commit.ts | 1 + .../__tests__/manual-reconciliation.test.ts | 236 ++++++++++ lib/reconciliation/__tests__/service.test.ts | 129 ++++++ lib/reconciliation/__tests__/signoff.test.ts | 122 +++++- lib/reconciliation/items.ts | 6 + lib/reconciliation/manual-reconciliation.ts | 413 ++++++++++++++++++ lib/reconciliation/schemas.ts | 33 +- lib/reconciliation/service.ts | 52 ++- lib/reconciliation/signoff.ts | 32 +- messages/en.json | 17 +- messages/sv.json | 17 +- skills/accounted-api/references/banking.md | 7 +- 22 files changed, 1312 insertions(+), 116 deletions(-) create mode 100644 lib/reconciliation/__tests__/manual-reconciliation.test.ts create mode 100644 lib/reconciliation/manual-reconciliation.ts diff --git a/DECISIONS.md b/DECISIONS.md index 9a6c8978..ef4a44a3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1194,3 +1194,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] Single-call chat console (general.help, AskConsole → /api/agent/ask) now carries the thread's earlier turns into every model call, via a new optional `history` on the provider-agnostic GenerateTextRequest (real message turns before the prompt in BOTH adapters: Anthropic-family messages array, OpenAI-compatible via AI SDK `messages`; an absent/empty history leaves the request byte-identical to the single-turn call, so hosted extraction and every other caller are untouched). The 08-20 RIP-3 cutover made each turn stateless (conversationId was only the tool actor id), so a follow-up in a resumed thread was answered blind (user report: "frågar vad jag refererar till"). History is loaded server-side from agent_messages (loadChatHistory: text only, hidden + tool rows dropped, alternation repaired, newest 16 rows / 10k chars) rather than sent by the client, so the client cannot forge earlier turns and old streaming threads replay cleanly. Rejected: inlining a transcript into the prompt (works everywhere but weaker turn semantics and blurs data vs instructions) and loading history in AskConsole (client-trusted history). Separately: the docked assistant panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and reopens it after a full reload (the deploy prompt's "Ladda om" wiped it); sessionStorage, not user_preferences, because this is this-tab-this-session state that must not follow the user to other devices or tabs. And DeployReloadPrompt's full-width wrapper gets pointer-events-none: at z-[60] after the panel in DOM order it swallowed clicks on the panel's composer ("går ej att skriva"). [2026-08-25] /reports/bank-reconciliation retired behind a redirect to /reconciliation instead of kept as a "power" page: everything it did (matcher, manual N:1 matching, residual booking, IB tag, move-to-account) lives on the account-keyed page, and two reconciliation surfaces meant two truths. The catalog slug stays so old links, the report library and ?autorun=1 deep links keep working. [2026-08-25] reconciliation_residual staged op tiered 'medium', not create_voucher's 'high': it books one typed verifikat (6570/8410/8310/3740 vs bank) bounded by RESIDUAL_MAX_AMOUNT and is undone by storno + unmatch, i.e. the same blast radius as categorize_transaction. Scope is transactions:write (same as the v1 route) because it writes the ledger. +[2026-08-24] Manual reconciliation adapter (Reko bilagor, PR 1) computes the ledger side per fiscal period via generateTrialBalance (IB + movement through the balansdag), never as an all-history sumAccountBalance: year-end posts an opening_balance verifikat that re-books every balance account in the new year, so an all-history sum counts a closed year twice. Reskontra/semesterskuld specifications are "per idag" (open items now), labeled so in the bridge; a per-date reskontra is a follow-up. A typed external_balance is accepted only on manual accounts without a system specification (EXTERNAL_BALANCE_NOT_ALLOWED elsewhere): letting a stated number override the bank, Skatteverket or the reskontra would hide the very difference the sign-off exists to record. diff --git a/app/api/reconciliation/accounts/[accountKey]/signoff/route.ts b/app/api/reconciliation/accounts/[accountKey]/signoff/route.ts index 888c404a..56fc3dbc 100644 --- a/app/api/reconciliation/accounts/[accountKey]/signoff/route.ts +++ b/app/api/reconciliation/accounts/[accountKey]/signoff/route.ts @@ -11,6 +11,8 @@ const SignoffBodySchema = z.object({ through_date: z.string().regex(ISO_DATE_RE), note: z.string().max(2000).nullable().optional(), force: z.boolean().optional(), + /** Manual accounts without a system specification: the balance per the signer's underlag, ledger sign. */ + external_balance: z.number().finite().nullable().optional(), dry_run: z.boolean().optional(), }) @@ -65,7 +67,12 @@ export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> } companyId, user.id, accountKey, - { through_date: parsed.data.through_date, note: parsed.data.note ?? null, force: parsed.data.force }, + { + through_date: parsed.data.through_date, + note: parsed.data.note ?? null, + force: parsed.data.force, + external_balance: parsed.data.external_balance ?? null, + }, { dryRun: parsed.data.dry_run === true }, ) if (!result) { diff --git a/app/api/reconciliation/accounts/__tests__/signoff-route.test.ts b/app/api/reconciliation/accounts/__tests__/signoff-route.test.ts index de528576..a52973e7 100644 --- a/app/api/reconciliation/accounts/__tests__/signoff-route.test.ts +++ b/app/api/reconciliation/accounts/__tests__/signoff-route.test.ts @@ -93,11 +93,39 @@ describe('dashboard sign-off routes', () => { 'company-1', 'user-1', 'skattekonto', - { through_date: '2026-07-31', note: 'ok', force: undefined }, + { through_date: '2026-07-31', note: 'ok', force: undefined, external_balance: null }, { dryRun: true }, ) }) + it('POST forwards a stated external_balance for a manual account and 400s a non-numeric one', async () => { + const res = await signPOST( + createMockRequest('http://localhost/api/reconciliation/accounts/manual:2350/signoff', { method: 'POST', body: { + through_date: '2026-07-31', + external_balance: -250000, + note: 'Enligt engagemangsbesked', + } }), + p({ accountKey: 'manual:2350' }), + ) + expect(res.status).toBe(200) + expect(signMock).toHaveBeenCalledWith( + supabase, + 'company-1', + 'user-1', + 'manual:2350', + { through_date: '2026-07-31', note: 'Enligt engagemangsbesked', force: undefined, external_balance: -250000 }, + { dryRun: false }, + ) + const bad = await signPOST( + createMockRequest('http://localhost/api/reconciliation/accounts/manual:2350/signoff', { method: 'POST', body: { + through_date: '2026-07-31', + external_balance: '250 000', + } }), + p({ accountKey: 'manual:2350' }), + ) + expect(bad.status).toBe(400) + }) + it('POST 400s a missing or malformed through_date', async () => { const res = await signPOST( createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff', { method: 'POST', body: { through_date: '31/07/2026' } }), diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route.ts index e11ee0ac..41758cb7 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route.ts @@ -24,6 +24,7 @@ const SignoffRequest = z.object({ through_date: z.string().regex(ISO_DATE_RE), note: z.string().max(2000).nullable().optional(), force: z.boolean().optional(), + external_balance: z.number().finite().nullable().optional(), }) const SignoffResponse = z.object({ @@ -89,11 +90,11 @@ registerEndpoint({ path: '/api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff', summary: 'Mark an account reconciled through a date (sign-off).', description: - 'Body: { through_date: "YYYY-MM-DD", note?, force? }. Recomputes the bridge through the date and refuses unless unexplained_difference is zero; with force: true and a note it signs anyway and records the difference. Refuses dates in the future, dates past the skattekonto snapshot (NOT_FETCHED_THROUGH), and dates at or before an existing active sign-off (ALREADY_SIGNED_OFF: reopen that one first). ?dry_run=true returns would_sign without writing. Undo with POST .../signoff/{signoffId}/reopen.', + 'Body: { through_date: "YYYY-MM-DD", note?, force?, external_balance? }. Recomputes the bridge through the date and refuses unless unexplained_difference is zero; with force: true and a note it signs anyway and records the difference. Refuses dates in the future, dates past the skattekonto snapshot (NOT_FETCHED_THROUGH), and dates at or before an existing active sign-off (ALREADY_SIGNED_OFF: reopen that one first). For a manual:NNNN account without a system specification (anything but 1510/2440/2920/2940), external_balance is the balance per the signer\'s underlag in ledger sign (liabilities negative); the difference against the booked balance is recorded, and a non-zero one still needs force + note. On bank, skattekonto and specification accounts external_balance is refused (EXTERNAL_BALANCE_NOT_ALLOWED). ?dry_run=true returns would_sign without writing. Undo with POST .../signoff/{signoffId}/reopen.', useWhen: 'The month (or period) is explained and you want the account marked as reconciled through its last day, as a human would in the Avstämning page.', doNotUseFor: 'Linking rows or booking anything: a sign-off changes no data in the ledger. Use .../links and the booking endpoints first.', pitfalls: [ - 'Refusal codes come back as VALIDATION_ERROR with details.code: INVALID_DATE, DATE_IN_FUTURE, NOT_FETCHED_THROUGH, OUTSIDE_UNKNOWN, NOT_RECONCILED, NOTE_REQUIRED; ALREADY_SIGNED_OFF and SIGNOFF_RACE come back as CONFLICT.', + 'Refusal codes come back as VALIDATION_ERROR with details.code: INVALID_DATE, DATE_IN_FUTURE, NOT_FETCHED_THROUGH, OUTSIDE_UNKNOWN, NOT_RECONCILED, NOTE_REQUIRED, EXTERNAL_BALANCE_NOT_ALLOWED; ALREADY_SIGNED_OFF and SIGNOFF_RACE come back as CONFLICT.', 'force: true without a note is NOTE_REQUIRED: the note is what the next reader sees next to the non-zero difference.', 'Idempotency-Key is required; repeating the same key replays the first response.', ], @@ -169,7 +170,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey: ctx.companyId!, ctx.userId, accountKey, - { through_date: parsed.data.through_date, note: parsed.data.note ?? null, force: parsed.data.force }, + { + through_date: parsed.data.through_date, + note: parsed.data.note ?? null, + force: parsed.data.force, + external_balance: parsed.data.external_balance ?? null, + }, { dryRun: ctx.dryRun }, ) if (!result) { diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/__tests__/signoff-route.test.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/__tests__/signoff-route.test.ts index f77496aa..3420507c 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/accounts/__tests__/signoff-route.test.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/__tests__/signoff-route.test.ts @@ -153,7 +153,7 @@ describe('v1 reconciliation sign-off', () => { COMPANY_ID, 'user-1', 'skattekonto', - { through_date: '2026-07-31', note: 'ok', force: undefined }, + { through_date: '2026-07-31', note: 'ok', force: undefined, external_balance: null }, { dryRun: false }, ) }) diff --git a/components/reconciliation/AccountOverview.tsx b/components/reconciliation/AccountOverview.tsx index 799e6eac..22c72ab5 100644 --- a/components/reconciliation/AccountOverview.tsx +++ b/components/reconciliation/AccountOverview.tsx @@ -21,7 +21,7 @@ import type { ReconciliationStatus, } from '@/lib/reconciliation/schemas' import type { SkattekontoBatchRowResult, SkattekontoTransactionWithSuggestion } from '@/types/skatteverket' -import { SignoffDialog } from './SignoffDialog' +import { SignoffDialog, type SignoffSubmitInput } from './SignoffDialog' import { MatcherPreview, type MatcherMatch } from './MatcherPreview' import { InfoTooltip } from '@/components/ui/info-tooltip' @@ -95,6 +95,10 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window, const autorunDone = useRef(false) const isSkv = account.kind === 'skattekonto' + // Manual accounts have no rows to match or book: the body is the balance + // bridge (IB, movement, UB against a specification or the signer's + // underlag) and the sign-off. + const isManual = account.kind === 'manual' const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}` const load = useCallback(async () => { @@ -251,7 +255,7 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window, } } - async function submitSignoff(input: { through_date: string; note: string | null; force: boolean }) { + async function submitSignoff(input: SignoffSubmitInput) { const res = await fetch(`${base}/signoff`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -405,6 +409,8 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window, const asOfDate = status.as_of.slice(0, 10) const fetchedAt = isSkv ? status.skattekonto?.fetched_at : account.source.synced_at const sourceLabel = isSkv ? t('source_skv') : t('source_bank') + const specification = isManual ? (status.manual?.specification ?? null) : null + const specificationLabel = specification ? (locale === 'en' ? specification.label_en : specification.label_sv) : null const bankRaw = status.bank as { bank_transaction_inflow?: number; bank_transaction_outflow?: number; bank_transaction_count?: number } | null const bankBreakdown = @@ -416,21 +422,34 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window, }) : null + const externalTile = isManual + ? { + key: 'external', + label: specificationLabel ?? t('tile_external_manual'), + value: money(status.external_balance), + sub: specification + ? t('tile_spec_today') + : status.external_balance != null && status.signoff + ? t('tile_external_signed', { date: formatDate(status.signoff.through_date) }) + : t('tile_external_manual_sub'), + } + : { + key: 'external', + label: isSkv ? t('tile_external_skv') : t('tile_external_bank'), + // The bank tile is the period sum (what its label says), which lives on + // the bridge; external_balance is the reported bank balance and is often + // unknown, which rendered as "okänt" next to a bridge that knows better. + value: money( + isSkv + ? status.external_balance + : (status.bridge.find((l) => l.key === 'bank_transactions')?.amount ?? status.external_balance), + ), + // Bank: the gross split makes the net self-explanatory; skattekonto: when it was fetched. + sub: bankBreakdown ?? (fetchedAt ? t('tile_synced', { date: formatDate(fetchedAt) }) : t('rail_never_synced')), + } + const tiles: Array<{ key: string; label: string; value: string; sub: string; tone?: 'ok' | 'attn'; help?: string }> = [ - { - key: 'external', - label: isSkv ? t('tile_external_skv') : t('tile_external_bank'), - // The bank tile is the period sum (what its label says), which lives on - // the bridge; external_balance is the reported bank balance and is often - // unknown, which rendered as "okänt" next to a bridge that knows better. - value: money( - isSkv - ? status.external_balance - : (status.bridge.find((l) => l.key === 'bank_transactions')?.amount ?? status.external_balance), - ), - // Bank: the gross split makes the net self-explanatory; skattekonto: when it was fetched. - sub: bankBreakdown ?? (fetchedAt ? t('tile_synced', { date: formatDate(fetchedAt) }) : t('rail_never_synced')), - }, + externalTile, { key: 'ledger', label: isSkv @@ -574,7 +593,7 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window, {t('action_book_rows', { count: bookableIds.length })} )} - {!isSkv && ( + {!isSkv && !isManual && ( @@ -591,6 +610,13 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window, )} + {isManual && ( + + + {t('action_open_ledger')} + + + )} {items.older_unmatched_count > 0 && ( @@ -621,7 +647,11 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window, )} {/* The table: full width, banded by bucket, paired proposal rows. */} - {items.items.length === 0 ? ( + {isManual ? ( +

+ {specificationLabel ? t('manual_spec_hint', { label: specificationLabel }) : t('manual_hint')} +

+ ) : items.items.length === 0 ? (

{t('all_clear')}

) : (
@@ -730,6 +760,8 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window, maxDate={signoffMaxDate} unexplained={status.unexplained_difference} currency={currency} + askExternalBalance={isManual && !specification} + ledgerBalance={status.ledger_balance} onSubmit={submitSignoff} />
diff --git a/components/reconciliation/ReconciliationRail.tsx b/components/reconciliation/ReconciliationRail.tsx index c894076a..83dbf02d 100644 --- a/components/reconciliation/ReconciliationRail.tsx +++ b/components/reconciliation/ReconciliationRail.tsx @@ -1,5 +1,6 @@ 'use client' +import { Fragment } from 'react' import Image from 'next/image' import { useTranslations } from 'next-intl' import { cn, formatDate } from '@/lib/utils' @@ -9,6 +10,8 @@ import type { ReconciliationAccount } from '@/lib/reconciliation/schemas' * The account rail of the Avstämning page: one row per account with an * outside truth (bank accounts, the skattekonto), logo or monogram, the * account number, when its outside side was last fetched, and a status dot. + * The rest of the balance sheet follows under its own label: those accounts + * have no feed, so their row says whether they are signed off instead. * Selection is URL-owned (?account=) by the workspace; the rail only reports. */ @@ -40,15 +43,16 @@ export function AccountLogo({ account, className }: { account: ReconciliationAcc /> ) } + const label = account.kind === 'manual' ? account.account_number.slice(0, 2) : monogram(account.name) return ( - {monogram(account.name)} + {label} ) } @@ -61,68 +65,94 @@ interface ReconciliationRailProps { export function ReconciliationRail({ accounts, selectedKey, onSelect }: ReconciliationRailProps) { const t = useTranslations('reconciliation') + const fed = accounts.filter((a) => a.kind !== 'manual') + const manual = accounts.filter((a) => a.kind === 'manual') + + const stateLabel = (account: ReconciliationAccount): string => { + const state = account.status?.state ?? 'unknown' + if (account.kind === 'manual' && state === 'open') return t('state_unsigned') + return t(`state_${state === 'unknown' ? 'not_configured' : state}`) + } + + const subLine = (account: ReconciliationAccount): string => { + if (account.signed_off_through) return t('rail_signed_off', { date: formatDate(account.signed_off_through) }) + if (account.kind === 'manual') return t('rail_never_signed') + const synced = account.source.synced_at + return synced ? t('rail_synced', { date: formatDate(synced) }) : t('rail_never_synced') + } + + const renderRow = (account: ReconciliationAccount) => { + const selected = account.account_key === selectedKey + const state = account.status?.state ?? 'unknown' + const open = account.status + ? account.status.open_counts.proposed + + account.status.open_counts.unmatched_external + + account.status.open_counts.unmatched_ledger + : 0 + return ( +
  • + +
  • + ) + } + return ( ) diff --git a/components/reconciliation/SignoffDialog.tsx b/components/reconciliation/SignoffDialog.tsx index 2144bccb..ecb4c0b6 100644 --- a/components/reconciliation/SignoffDialog.tsx +++ b/components/reconciliation/SignoffDialog.tsx @@ -16,13 +16,24 @@ import { DialogTitle, } from '@/components/ui/dialog' import { formatCurrency } from '@/lib/utils' +import { roundOre } from '@/lib/money' /** * "Markera som avstämd": date, optional note, and (only when the engine * reports an unexplained difference) the explicit "sign anyway" choice that - * makes the note mandatory. The policy lives in lib/reconciliation/signoff.ts; - * this dialog only collects the input and shows the server's refusal verbatim. + * makes the note mandatory. A manual account without a system specification + * also asks for the balance per the signer's underlag; the difference against + * the booked balance is then what needs explaining. The policy lives in + * lib/reconciliation/signoff.ts; this dialog only collects the input and + * shows the server's refusal verbatim. */ +export interface SignoffSubmitInput { + through_date: string + note: string | null + force: boolean + external_balance?: number | null +} + interface SignoffDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -33,8 +44,19 @@ interface SignoffDialogProps { maxDate: string unexplained: number | null currency: string + /** Ask for the balance per underlag (manual accounts without a system specification). */ + askExternalBalance?: boolean + /** The booked balance the stated one is compared with. */ + ledgerBalance?: number | null /** Returns an error message to show inline, or null on success. */ - onSubmit: (input: { through_date: string; note: string | null; force: boolean }) => Promise + onSubmit: (input: SignoffSubmitInput) => Promise +} + +function parseAmount(raw: string): number | null { + const normalized = raw.replace(/\s/g, '').replace(',', '.') + if (normalized === '' || normalized === '-') return null + const n = Number(normalized) + return Number.isFinite(n) ? roundOre(n) : null } export function SignoffDialog({ @@ -45,16 +67,24 @@ export function SignoffDialog({ maxDate, unexplained, currency, + askExternalBalance = false, + ledgerBalance = null, onSubmit, }: SignoffDialogProps) { const t = useTranslations('reconciliation') const [date, setDate] = useState(defaultDate) const [note, setNote] = useState('') const [force, setForce] = useState(false) + const [external, setExternal] = useState('') const [error, setError] = useState(null) const [busy, setBusy] = useState(false) - const needsForce = unexplained == null || Math.abs(unexplained) >= 0.005 + // With a stated balance the difference is against the booked balance; + // without one the engine's number (or "unknown") decides. + const stated = askExternalBalance ? parseAmount(external) : null + const effectiveUnexplained = + stated != null && ledgerBalance != null ? roundOre(ledgerBalance - stated) : unexplained + const needsForce = effectiveUnexplained == null || Math.abs(effectiveUnexplained) >= 0.005 // Reset per opening so a second sign-off does not inherit the last one's // note or override choice. @@ -63,6 +93,7 @@ export function SignoffDialog({ setDate(defaultDate) setNote('') setForce(false) + setExternal('') setError(null) } }, [open, defaultDate]) @@ -73,7 +104,12 @@ export function SignoffDialog({ setBusy(true) setError(null) try { - const message = await onSubmit({ through_date: date, note: note.trim() || null, force: needsForce && force }) + const message = await onSubmit({ + through_date: date, + note: note.trim() || null, + force: needsForce && force, + ...(askExternalBalance ? { external_balance: stated } : {}), + }) if (message) setError(message) } finally { setBusy(false) @@ -99,12 +135,32 @@ export function SignoffDialog({ className="tabular-nums" /> + {askExternalBalance && ( +
    + + setExternal(e.target.value)} + placeholder="0,00" + className="tabular-nums" + /> +

    + {ledgerBalance != null + ? t('signoff_external_balance_help', { amount: formatCurrency(ledgerBalance, currency) }) + : t('signoff_external_balance_optional')} +

    +
    + )} {needsForce && (

    - {unexplained == null - ? t('tile_unknown') - : t('signoff_unexplained_warning', { amount: formatCurrency(unexplained, currency) })} + {effectiveUnexplained == null + ? askExternalBalance + ? t('signoff_external_balance_optional') + : t('tile_unknown') + : t('signoff_unexplained_warning', { amount: formatCurrency(effectiveUnexplained, currency) })}