From e5fba9471e3ad5a8a50b4a88930bd76e94d43630 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 1 Sep 2026 20:05:36 +0200 Subject: [PATCH] feat(bookkeeping): verifikationsserie dropdown, wider Ny verifikat modal, balancing amount on focus (#2120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to the Ny verifikation modal. 1. Verifikationsserie is a closed dropdown, not a one-letter free-text field. Ships Fortnox's table verbatim from their Systemdokumentation (A Redovisning, B Kundfakturor, C Inbetalningar från kunder, D Leverantörsfakturor, E Utbetalningar till leverantörer, F Kassa, G Avskrivning, H Periodisering, I Bokslut, J Revisor, K Lön, L Kontantfaktura, M Momsrapport). A is deliberately Redovisning, not Kundfakturor: it is the general series manual entries land in, and migration 20260526120700 ships every source_type defaulting to 'A', so every existing company's A series already holds everything. Any letter the company already configured is appended so no existing value can fall out of the picker. 2. The modal goes from sm:max-w-3xl to sm:max-w-5xl lg:max-w-6xl. 3. Tabbing or clicking into an untouched amount proposes the outstanding difference, pre-selected so typing replaces it. Only fires when the row has an account, both amounts are empty, and the difference belongs on that side. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DnoyZCbfm2geRbwtUpi6XN --- DECISIONS.md | 1 + components/bookkeeping/JournalEntryForm.tsx | 91 +++++++++++++++---- .../bookkeeping/NewJournalEntryDialog.tsx | 6 +- .../__tests__/voucher-series-resolver.test.ts | 41 +++++++++ lib/bookkeeping/voucher-series-resolver.ts | 44 +++++++++ messages/en.json | 2 +- messages/sv.json | 2 +- 7 files changed, 166 insertions(+), 21 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index dd1e56bf..09ae3028 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1451,4 +1451,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-01] Skeptic BLOCK on woo failed-order removal fixed by: freeze guards repeated on the DELETE statement (TOCTOU), is_paid=false + legacy_transaction_id null guards, orderRemoves gated on !orderIsPaid. No BEFORE DELETE trigger/RPC: the is_paid guard makes the cascade race unreachable (refund children only exist under paid parents). [2026-09-01] EB claim guard, skeptic round (PR #2116): active-company standing state (enabled cash_accounts + enabled accounts on its live-ish rows) outranks sibling claims COMPANY-wide, not row-wide: a bank-list renewal arrives on a fresh row and must not switch a working feed off. pending_selection rows neither claim nor remember deselections (unconfirmed callback output; also stops fail-closed writes from poisoning later connects). Guard-disabled accounts are never mirrored from the callback (mirroring enabled:false can promote the seeded primary 1930 manual row and disable it under a foreign identity) and the selection save skips allocation+mirror for disabled never-mirrored accounts, so the no-slot-burned invariant holds end to end. Deselection carry got a picker note; enabling an account clears the guard flags. Legacy both-companies-enabled overlaps stay untouched (Swedish review advisory: prod sweep is a follow-up, not this PR). [2026-09-01] EB claim guard round 2 (skeptic re-verify): pending_selection rows are asymmetric, not excluded: their ENABLED accounts still claim (attach-created rows hold offered accounts with no cash rows until saved; excluding them reopened the attach-window double-booking), while their disabled flags stay out of deselection memory (unconfirmed callback output). Both fetchAllRows claim queries order('id'): unordered .range() pagination can silently skip rows at page boundaries, and a skipped row is a missed claim (fail-open). +[2026-09-01] Verifikationsserie in the Ny verifikation modal is a closed dropdown instead of a one-letter free-text field: a typo there silently opens a brand-new series with its own number sequence, and the letters only mean anything if everyone uses the same ones. The letters are NOT prescribed by law (BFL 5 kap. 7 § requires only unbroken systematic numbering within each series), and the incumbents disagree: Björn Lundén uses A Huvudserie, F Kundfakturor, I Inbetalningar, L Leverantörsfakturor, N Löner, U Utbetalningar, J Bokslut. We ship FORTNOX's table verbatim (A Redovisning, B Kundfakturor, C Inbetalningar från kunder, D Leverantörsfakturor, E Utbetalningar till leverantörer, F Kassa, G Avskrivning, H Periodisering, I Bokslut, J Revisor, K Lön, L Kontantfaktura, M Momsrapport), from their own Systemdokumentation, because Fortnox is the system most companies migrate here from and an imported ledger should keep its meaning. REJECTED an earlier draft that labelled A as Kundfakturor: A is the general series manual entries land in (the one point Fortnox and BL agree on, and Fortnox allows manuell kontering ONLY in A), and migration 20260526120700 ships every source_type defaulting to 'A', so every existing company's A series already holds everything. Calling it Kundfakturor would mislabel their entire history and the modal's own default. The list is closed but any letter the company already configured, or that a draft was saved with, is appended so no existing value can fall out of the picker. Also: tabbing or clicking into an untouched amount field now proposes the outstanding difference (pre-selected, so typing replaces it) when the row already has an account and the difference belongs on that side. This deliberately reverses part of the note in updateLine that said a balancing amount must never auto-fill: that note was about filling on ACCOUNT selection, which stole the amount before the user had a chance to split it. Filling on focus keeps the split case intact because the proposal is selected text, and it fixes the common moms case where the last line is just the remainder. [2026-09-01] Settings PUT cross-field VAT validations scoped to touched field groups (vat-completeness, 40m-monthly, periodisk sammanstallning), not fixed at onboarding: partial saves from surfaces without VAT fields (invoice bank-details dialog) were hard-blocked by pre-existing vat_registered-without-number state (Marketio Lab case). The invariant still holds on every save that touches its group; explicit null now counts as a clear instead of falling back to the stored value during validation. Onboarding-side VAT number collection left as follow-up. diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index dee72af5..6656ffe1 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -45,7 +45,7 @@ import { } from '@/lib/documents/link-documents' import { formatCurrency } from '@/lib/utils' import { roundOre } from '@/lib/money' -import { formatVoucher, resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver' +import { formatVoucher, resolveDefaultSeriesForSource, VOUCHER_SERIES_PRESETS } from '@/lib/bookkeeping/voucher-series-resolver' import { resolveFxLineSlot } from '@/lib/bookkeeping/fx-line-slot' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import { useCompany } from '@/contexts/CompanyContext' @@ -193,6 +193,11 @@ export default function JournalEntryForm({ // effect below. const seriesMapRef = useRef | null>(null) const defaultSeriesRef = useRef('A') + // Series letters this company has configured beyond the fixed presets. The + // dropdown is a closed list, so anything already in use (a legacy letter, an + // override in the per-source-type map) has to stay selectable: otherwise the + // Select would render blank on a value it does not offer. + const [configuredSeries, setConfiguredSeries] = useState([]) // Mirror of effectiveSourceType for the settings-fetch callback: if a template // routed the source type before /api/settings resolved, the late callback must // re-apply the series for the ROUTED type, not the mount-time base (otherwise @@ -274,6 +279,18 @@ export default function JournalEntryForm({ setVoucherSeries(perSource !== 'A' ? perSource : defaultSeriesRef.current || 'A') }, []) + // The series picker: the fixed Swedish presets first, then any letter this + // company already uses (settings map, global default, or the series a draft + // was saved with) so no existing value falls out of the list. + const seriesOptions = useMemo(() => { + const options = VOUCHER_SERIES_PRESETS.map((p) => ({ letter: p.letter, label: p.label })) + const seen = new Set(options.map((o) => o.letter)) + const extras = [...configuredSeries, voucherSeries] + .filter((letter) => /^[A-Z]$/.test(letter) && !seen.has(letter) && seen.add(letter)) + .sort() + return [...options, ...extras.map((letter) => ({ letter, label: '' }))] + }, [configuredSeries, voucherSeries]) + useEffect(() => { loadBasCatalog().then(setCatalog).catch(() => {/* search degrades to the active chart */}) }, []) @@ -289,6 +306,16 @@ export default function JournalEntryForm({ seriesMapRef.current = (companySettings.default_voucher_series_per_source_type as Record | null) ?? null defaultSeriesRef.current = companySettings.default_voucher_series || 'A' + setConfiguredSeries( + Array.from( + new Set( + [ + ...Object.values(seriesMapRef.current || {}), + defaultSeriesRef.current, + ].filter((v): v is string => typeof v === 'string' && /^[A-Z]$/.test(v)), + ), + ), + ) if (!embedded && !editEntryId) { applySeriesForSourceType(effectiveSourceTypeRef.current) } @@ -622,6 +649,28 @@ export default function JournalEntryForm({ updateLine(index, side === 'debit' ? 'debit_amount' : 'credit_amount', fill.toFixed(2)) } + // Tabbing (or clicking) into an untouched amount proposes the outstanding + // difference, so the closing row of a moms-split voucher fills itself. The + // proposal is pre-selected: typing replaces it, which keeps a multi-row split + // exactly as fast as before. Guards: the row must already have an account (so + // you can tab through the trailing blank row), both amounts must still be + // empty (never overwrite a typed figure), and the difference must belong on + // this side. + const handleAmountFocus = + (index: number, side: 'debit' | 'credit') => + (e: React.FocusEvent) => { + const target = e.currentTarget + const line = lines[index] + if (!line || !line.account_number) return + if (line.debit_amount || line.credit_amount) return + const diff = computeBalancingDiff(index) + const fill = side === 'debit' ? diff : -diff + if (fill <= 0) return + updateLine(index, side === 'debit' ? 'debit_amount' : 'credit_amount', fill.toFixed(2)) + // Select after the controlled re-render has written the value. + requestAnimationFrame(() => target.select()) + } + // Move focus to a row's input. Deferred a frame so it runs after any // re-render (e.g. the auto-appended trailing row). offsetParent is null for // display:none elements, so this picks whichever layout is currently visible. @@ -1362,24 +1411,26 @@ export default function JournalEntryForm({ /> {!embedded && ( -
+ // Closed list, not free text: the letters carry fixed meanings + // (A = redovisning, B = kundfakturor, ...) and a typo here silently + // starts a new series with its own number sequence. +
- { - const v = e.target.value.toUpperCase().replace(/[^A-Z]/g, '').slice(-1) - setVoucherSeries(v) - }} - onFocus={(e) => { - const target = e.target - setTimeout(() => target.select(), 0) - }} - onBlur={() => { - if (!voucherSeries) setVoucherSeries('A') - }} - className="mt-1 h-8 text-center font-mono" - maxLength={1} - /> +
)}
@@ -1594,6 +1645,7 @@ export default function JournalEntryForm({ value={line.debit_amount} onChange={(e) => updateLine(index, 'debit_amount', e.target.value)} onKeyDown={handleAmountKeyDown(index, 'debit')} + onFocus={handleAmountFocus(index, 'debit')} onDoubleClick={() => handleFillBalance(index, 'debit')} title={t('fill_balance_tooltip')} placeholder="0,00" @@ -1611,6 +1663,7 @@ export default function JournalEntryForm({ value={line.credit_amount} onChange={(e) => updateLine(index, 'credit_amount', e.target.value)} onKeyDown={handleAmountKeyDown(index, 'credit')} + onFocus={handleAmountFocus(index, 'credit')} onDoubleClick={() => handleFillBalance(index, 'credit')} title={t('fill_balance_tooltip')} placeholder="0,00" @@ -1748,6 +1801,7 @@ export default function JournalEntryForm({ value={line.debit_amount} onChange={(e) => updateLine(index, 'debit_amount', e.target.value)} onKeyDown={handleAmountKeyDown(index, 'debit')} + onFocus={handleAmountFocus(index, 'debit')} onDoubleClick={() => handleFillBalance(index, 'debit')} title={t('fill_balance_tooltip')} placeholder="0,00" @@ -1764,6 +1818,7 @@ export default function JournalEntryForm({ value={line.credit_amount} onChange={(e) => updateLine(index, 'credit_amount', e.target.value)} onKeyDown={handleAmountKeyDown(index, 'credit')} + onFocus={handleAmountFocus(index, 'credit')} onDoubleClick={() => handleFillBalance(index, 'credit')} title={t('fill_balance_tooltip')} placeholder="0,00" diff --git a/components/bookkeeping/NewJournalEntryDialog.tsx b/components/bookkeeping/NewJournalEntryDialog.tsx index b0c20c3f..c4420739 100644 --- a/components/bookkeeping/NewJournalEntryDialog.tsx +++ b/components/bookkeeping/NewJournalEntryDialog.tsx @@ -64,7 +64,11 @@ export default function NewJournalEntryDialog({ return ( { @@ -192,3 +194,42 @@ describe('parseVoucher', () => { expect(parseVoucher('A 1')).toBeNull() }) }) + +describe('VOUCHER_SERIES_PRESETS', () => { + it('offers the conventional Swedish series in a stable order', () => { + expect(VOUCHER_SERIES_PRESETS.map((p) => p.letter)).toEqual( + 'ABCDEFGHIJKLM'.split(''), + ) + }) + + it('gives every preset a non-empty label', () => { + for (const preset of VOUCHER_SERIES_PRESETS) { + expect(preset.label.length).toBeGreaterThan(0) + } + }) + + it('uses letters the resolver accepts as a series', () => { + for (const preset of VOUCHER_SERIES_PRESETS) { + expect( + resolveDefaultSeriesForSource({ manual: preset.letter }, 'manual'), + ).toBe(preset.letter) + } + }) +}) + +describe('voucherSeriesLabel', () => { + it('describes a preset letter', () => { + // A is the general series manual entries land in, not kundfakturor: it is + // the shipped default for every source_type. Guards against a relabelling + // that would mislabel every existing company's history. + expect(voucherSeriesLabel('A')).toBe('Redovisning') + expect(voucherSeriesLabel('B')).toBe('Kundfakturor') + expect(voucherSeriesLabel('K')).toBe('Lön') + }) + + it('returns an empty string for a letter with no preset meaning', () => { + expect(voucherSeriesLabel('N')).toBe('') + expect(voucherSeriesLabel('Z')).toBe('') + expect(voucherSeriesLabel('')).toBe('') + }) +}) diff --git a/lib/bookkeeping/voucher-series-resolver.ts b/lib/bookkeeping/voucher-series-resolver.ts index 2dd4009a..f3a77886 100644 --- a/lib/bookkeeping/voucher-series-resolver.ts +++ b/lib/bookkeeping/voucher-series-resolver.ts @@ -21,6 +21,50 @@ export type VoucherSeriesMap = Partial> & const SERIES_LETTER_RE = /^[A-Z]$/ +/** + * The conventional Swedish verifikationsserier and what each one is for. + * + * The column accepts any A-Z letter, but a free-text field invites typos and + * lets the same letter mean different things across a company's ledger, so the + * entry form offers this fixed set (plus whatever series the company has + * already configured or used). + * + * The letters are NOT prescribed by law: BFL 5 kap. 7 § only requires an + * unbroken, systematically ordered numbering within each series. This list is + * Fortnox's, taken verbatim from their Systemdokumentation (Fortnox Lön, + * section 4 Behandlingsregler), because Fortnox is the system most companies + * migrate here from and an imported ledger should keep its meaning. Note that + * the incumbents disagree with each other: Björn Lundén uses F for + * kundfakturor, L for leverantörsfakturor and N for löner. The one point + * they agree on is that A is the general series you post manual entries into, + * which also matches this codebase: every source_type in + * company_settings.default_voucher_series_per_source_type ships as 'A'. + * + * The labels are bookkeeping-domain terms that stay Swedish in both locales, + * same convention as VoucherSeriesPerSourceTypeForm. + */ +export const VOUCHER_SERIES_PRESETS: ReadonlyArray<{ letter: string; label: string }> = [ + { letter: 'A', label: 'Redovisning' }, + { letter: 'B', label: 'Kundfakturor' }, + { letter: 'C', label: 'Inbetalningar från kunder' }, + { letter: 'D', label: 'Leverantörsfakturor' }, + { letter: 'E', label: 'Utbetalningar till leverantörer' }, + { letter: 'F', label: 'Kassa' }, + { letter: 'G', label: 'Avskrivning' }, + { letter: 'H', label: 'Periodisering' }, + { letter: 'I', label: 'Bokslut' }, + { letter: 'J', label: 'Revisor' }, + { letter: 'K', label: 'Lön' }, + { letter: 'L', label: 'Kontantfaktura' }, + { letter: 'M', label: 'Momsrapport' }, +] + +/** Swedish description for a preset series letter; empty for unknown letters. */ +export function voucherSeriesLabel(letter: string): string { + const match = VOUCHER_SERIES_PRESETS.find((p) => p.letter === letter) + return match ? match.label : '' +} + /** * Resolve the default voucher_series letter for a given source_type from a * company_settings row. Returns 'A' as a safe fallback when no mapping is diff --git a/messages/en.json b/messages/en.json index b758b19f..8d265d88 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5495,7 +5495,7 @@ "toast_draft_missing_docs_title": "Draft saved, but documents are missing", "toast_draft_missing_docs_description": "The draft was saved, but {count} document(s) could not be attached: {files}. Open the draft to attach them again.", "toast_open_entry": "Open the entry", - "fill_balance_tooltip": "Double-click to fill the balancing amount", + "fill_balance_tooltip": "The balancing amount is proposed automatically. Double-click to fill it again.", "keyboard_hint": "Enter jumps to the next field: once the entry balances, Enter opens the review.", "review_month_changed": "Note: different month than the previous voucher ({prev} → {current}).", "review_period_locked": "This period is closed or locked: posting may be rejected.", diff --git a/messages/sv.json b/messages/sv.json index 0e0abf25..f4b09be4 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -5495,7 +5495,7 @@ "toast_draft_missing_docs_title": "Utkast sparat, men underlag saknas", "toast_draft_missing_docs_description": "Utkastet sparades, men {count} underlag kunde inte bifogas: {files}. Öppna utkastet för att bifoga på nytt.", "toast_open_entry": "Öppna verifikatet", - "fill_balance_tooltip": "Dubbelklicka för att fylla i balanserande belopp", + "fill_balance_tooltip": "Balanserande belopp föreslås automatiskt. Dubbelklicka för att fylla i det igen.", "keyboard_hint": "Enter hoppar till nästa fält: när verifikatet balanserar öppnar Enter granskningen.", "review_month_changed": "Obs: annan månad än föregående verifikat ({prev} → {current}).", "review_period_locked": "Perioden är stängd eller låst: bokföring kan nekas.",