feat(bookkeeping): verifikationsserie dropdown, wider Ny verifikat modal, balancing amount on focus (#2120)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnoyZCbfm2geRbwtUpi6XN
This commit is contained in:
Jakob Wennberg
2026-09-01 20:05:36 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 0406e628e1
commit e5fba9471e
7 changed files with 166 additions and 21 deletions
@@ -4,6 +4,8 @@ import {
formatVoucher,
parseVoucher,
resolveDefaultSeriesForSource,
voucherSeriesLabel,
VOUCHER_SERIES_PRESETS,
} from '../voucher-series-resolver'
describe('resolveDefaultSeriesForSource', () => {
@@ -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('')
})
})
@@ -21,6 +21,50 @@ export type VoucherSeriesMap = Partial<Record<JournalEntrySourceType, string>> &
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