From 0c854ac54fbbcf97e05f82cfc26803f74e46f915 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:20:42 +0200 Subject: [PATCH] feat(settings): let a company name each verifikationsserie letter (#2336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): let a company name each verifikationsserie letter The series pickers show a fixed preset label next to every letter (A Redovisning ... M Momsrapport, Fortnox's layout). A byrå that lays its series out differently sees a wrong or missing name in every dropdown: a partner running löner on L saw "Kontantfaktura" in the verifikat form and asked for the series name. - company_settings.voucher_series_labels JSONB ({"L": "Lön"}), keys A-Z, values 1 to 40 chars, CHECK on the JSON shape. Display only; the engine never reads it. - UpdateSettingsSchema validates the map, trims names and strips empty values so a cleared field removes the name. - voucherSeriesLabel(letter, labels) is the one place that decides what a letter is called: company name, then preset, then empty. buildVoucherSeriesOptions replaces the three near-identical option builders in the verifikat form and the two settings pickers. - The Verifikationsserier list in settings edits the names: rows are the union of used, configured and named letters, one save button. - The SIE import review's two series pickers show the name too. Migration applied to staging (metjnjrhvujscngnpzdv) as 20260906131300. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC * fix(settings): keep imported series in the list and unsaved names through a refetch Skeptic pass on the series-name editor refuted two things: - The rewritten list filtered voucher_sequences to single letters, dropping multi-character series (FT, LB, SKV, ...) that 54 production companies carry over from Fortnox and Bokio imports; the old list showed them with their highest number. Rows are now every used series plus the configured and named letters; only single-letter series get a name input, since those are what the pickers offer and the schema accepts. - The draft re-seeded on the identity of settings.voucher_series_labels, and the settings hook revalidates on window focus with a fresh object, so unsaved typing was wiped after any earlier save on the page. The re-seed is now keyed on the serialized content of the saved names. Also folds the "new series are created on first use" footnote back into the group help, which the rewrite had dropped. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC * fix(settings): name the default-series options and enforce the label shape in the database Review pass on #2336: - CodeRabbit: the Standardserie selector under Bokföring still rendered bare letters; it now shows the same name the other pickers do, through voucherSeriesLabel. - Compliance swarm (SOC 2 PI1.1, low): the key and length rules for voucher_series_labels lived only in UpdateSettingsSchema. Migration 20260906134700 adds voucher_series_labels_valid(jsonb) and swaps the object-only CHECK for one that mirrors the Zod rules (keys A-Z, values non-blank strings of at most 40 characters), so a write that bypasses /api/settings cannot store a map the pickers cannot handle. Applied to staging with its schema_migrations row; verified against good, empty, lowercase, blank, over-long, numeric and array inputs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC * test(pg): cover the voucher_series_labels CHECK against real Postgres The coverage gate refuses a migration that adds a function without a *.pg.test.ts. voucher_series_labels_valid(jsonb) and the constraint that wraps it now have one: accepts the empty map and single-letter keys with names of 1 to 40 characters, rejects lowercase and multi-letter keys, blank, over-long, numeric and null values, arrays and scalars, and leaves the row untouched after a refused write. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC --------- Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 2 + app/api/settings/__tests__/route.test.ts | 31 ++- components/bookkeeping/JournalEntryForm.tsx | 18 +- components/import/ImportReviewStep.tsx | 9 +- components/settings/VoucherSeriesManager.tsx | 199 +++++++++++++++--- .../VoucherSeriesPerCashAccountForm.tsx | 38 ++-- .../VoucherSeriesPerSourceTypeForm.tsx | 34 ++- .../sections/BookkeepingSettingsContent.tsx | 18 +- lib/api/__tests__/schemas.test.ts | 42 ++++ lib/api/schemas.ts | 14 ++ .../__tests__/voucher-series-resolver.test.ts | 51 +++++ lib/bookkeeping/voucher-series-resolver.ts | 54 ++++- messages/en.json | 9 +- messages/sv.json | 9 +- .../20260906131300_voucher_series_labels.sql | 28 +++ ...0906134700_voucher_series_labels_check.sql | 44 ++++ tests/helpers.ts | 1 + tests/pg/voucher-series-labels.pg.test.ts | 113 ++++++++++ types/index.ts | 8 + 19 files changed, 633 insertions(+), 89 deletions(-) create mode 100644 supabase/migrations/20260906131300_voucher_series_labels.sql create mode 100644 supabase/migrations/20260906134700_voucher_series_labels_check.sql create mode 100644 tests/pg/voucher-series-labels.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 91a931a7..b30be369 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1608,3 +1608,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-05] Cross-tab company guard (WL-09) stays a blocking two-exit dialog, founder re-confirmed today after a forensic pass on a real firing (a switch made elsewhere under the same login, no server-side or agent path involved): auto-follow, per-tab company scoping and a reads-continue banner were offered and declined. Only change: the dialog now names the company the other tab switched to (resolved from the memberships the shell already ships to the client, no request), so the two exits read as a choice between two named companies instead of a named one and "the new one". [2026-09-05] Björn Lundén connect: a 403 whose body says "out of allowed scope for service provider" is mapped to its own BL_INTEGRATION_NOT_ACTIVATED verdict (the key is right, the company never activated the integration) instead of the generic "leverantören avvisade autentiseringen"; live-verified against a real customer key, where every read endpoint answered exactly that while a made-up key answered 500. Root cause of every failed BL connect in prod (10 consents, only BL's own sandbox company ever got tokens): the integration is still a sandbox listing at BL, so no real company can activate it. Chose a message that names the fix (activate in Lundify, else SIE) over hiding the provider state; the Lundify activation-redirect flow and document/line-level fetching are filed as follow-ups rather than built blind before BL releases the integration. [2026-09-05] SIE precheck refuses a closed or locked containing year up front (conflict verdict with the remedy: Öppna igen / Lås upp) instead of letting the voucher RPC fail with the trigger text; the årsredovisning warns when the comparison year has no entries instead of deriving BR comparatives from the IB voucher: derivation would hide that the RR comparatives are still unknown, and manual/IB comparatives after a migration are a product decision (follow-up issue). +[2026-09-06] Voucher series names live on the existing Verifikationsserier list in settings, not a separate group: the list already enumerates the letters in use, and a name belongs next to the letter it names. Rows are the union of used, configured and named letters so a freshly assigned series can be named before its first verifikat. +[2026-09-06] Declined the request to import only SIE accounts with IB, UB or saldo <> 0: an inactive account is a harmless row in the chart, and dropping accounts breaks re-imports of later years that reference them. The chart imports whole. diff --git a/app/api/settings/__tests__/route.test.ts b/app/api/settings/__tests__/route.test.ts index ad0c8740..6b99b643 100644 --- a/app/api/settings/__tests__/route.test.ts +++ b/app/api/settings/__tests__/route.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' -const { supabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase() +const { supabase, enqueue, enqueueMany, reset, findCall } = createQueuedMockSupabase() const requireAuthMock = vi.fn() // The payee write-through is its own unit (lib/cash-accounts/__tests__/invoice-payee.test.ts); @@ -103,6 +103,35 @@ describe('PUT /api/settings', () => { expect(deadlineMocks.regenerate).not.toHaveBeenCalled() }) + it('stores voucher_series_labels with trimmed names and cleared letters stripped', async () => { + enqueueMany([ + { data: { entity_type: 'aktiebolag', onboarding_complete: true } }, // fetch oldSettings + { data: { id: 's1', voucher_series_labels: { L: 'Lön' } } }, // update ... returning + { data: null, count: 5 }, // deadlines count + ]) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { voucher_series_labels: { L: ' Lön ', K: '', M: ' ' } }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { voucher_series_labels: Record } }>(response) + + expect(status).toBe(200) + expect(body.data.voucher_series_labels).toEqual({ L: 'Lön' }) + // The row receives the normalized map: the cleared letters never reach the DB. + expect(findCall('company_settings', 'update')?.[0]).toEqual({ voucher_series_labels: { L: 'Lön' } }) + }) + + it('rejects a voucher_series_labels key that is not a single uppercase letter', async () => { + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { voucher_series_labels: { lön: 'Lön' } }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + expect(response.status).toBe(400) + }) + it('accepts the mileage_enabled visibility toggle', async () => { enqueueMany([ { data: { entity_type: 'enskild_firma', onboarding_complete: true } }, // fetch oldSettings diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 6495d8a8..b5c4fdb8 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, VOUCHER_SERIES_PRESETS } from '@/lib/bookkeeping/voucher-series-resolver' +import { buildVoucherSeriesOptions, formatVoucher, resolveDefaultSeriesForSource } 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' @@ -298,15 +298,13 @@ export default function JournalEntryForm({ // 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]) + // was saved with) so no existing value falls out of the list. Names come + // from the company's own voucher_series_labels, preset text as fallback. + const seriesLabels = companySettings?.voucher_series_labels ?? null + const seriesOptions = useMemo( + () => buildVoucherSeriesOptions(seriesLabels, [...configuredSeries, voucherSeries]), + [seriesLabels, configuredSeries, voucherSeries], + ) useEffect(() => { loadBasCatalog().then(setCatalog).catch(() => {/* search degrades to the active chart */}) diff --git a/components/import/ImportReviewStep.tsx b/components/import/ImportReviewStep.tsx index 16798615..d58527ff 100644 --- a/components/import/ImportReviewStep.tsx +++ b/components/import/ImportReviewStep.tsx @@ -37,6 +37,7 @@ import { } from '@/lib/import/opening-balance-defaults' import type { ImportPreview, AccountMapping } from '@/lib/import/types' import type { TheaterModel } from '@/lib/import/theater-model' +import { voucherSeriesLabel } from '@/lib/bookkeeping/voucher-series-resolver' const SERIES_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') @@ -75,6 +76,8 @@ export default function ImportReviewStep({ const { company } = useCompany() const { settings: companySettings } = useCompanySettings() const companyDefaultVoucherSeries = companySettings?.default_voucher_series || null + // Company-defined series names (presets as fallback) for the two pickers. + const seriesLabels = companySettings?.voucher_series_labels ?? null const t = useTranslations('import') const [options, setOptions] = useState({ createFiscalPeriod: true, @@ -394,9 +397,10 @@ export default function ImportReviewStep({ : isExisting ? ', används redan' : '' + const name = voucherSeriesLabel(letter, seriesLabels) return ( - {`Serie ${letter}${suffix}`} + {`Serie ${letter}${name ? ` ${name}` : ''}${suffix}`} ) })} @@ -472,9 +476,10 @@ export default function ImportReviewStep({ : isExisting ? ', används redan' : '' + const name = voucherSeriesLabel(letter, seriesLabels) return ( - {`Serie ${letter}${suffix}`} + {`Serie ${letter}${name ? ` ${name}` : ''}${suffix}`} ) })} diff --git a/components/settings/VoucherSeriesManager.tsx b/components/settings/VoucherSeriesManager.tsx index fcec4a5e..cbbc7b40 100644 --- a/components/settings/VoucherSeriesManager.tsx +++ b/components/settings/VoucherSeriesManager.tsx @@ -1,11 +1,22 @@ 'use client' import { useTranslations } from 'next-intl' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' +import { Loader2 } from 'lucide-react' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' import { Skeleton } from '@/components/ui/skeleton' -import { SettingsGroup, SettingsRowNote } from '@/components/settings/SettingsRows' +import { Button } from '@/components/ui/button' +import { useToast } from '@/components/ui/use-toast' +import { + SettingsGroup, + SettingsInput, + SettingsRow, + SettingsRowNote, +} from '@/components/settings/SettingsRows' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { voucherSeriesLabel } from '@/lib/bookkeeping/voucher-series-resolver' +import type { CompanySettings } from '@/types' interface VoucherSeries { voucher_series: string @@ -14,15 +25,65 @@ interface VoucherSeries { } interface VoucherSeriesManagerProps { - defaultSeries?: string + settings: Pick< + CompanySettings, + 'default_voucher_series' | 'default_voucher_series_per_source_type' | 'voucher_series_labels' + > + onSettingsUpdated: (settings: Partial) => void } -/** Read-only list of the series that have actually been used. */ -export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProps) { +const SERIES_LETTER_RE = /^[A-Z]$/ +const NAME_MAX_LENGTH = 40 + +/** + * The series this company uses, with a name per letter. + * + * Rows are the union of every series that has vouchers (voucher_sequences, + * including multi-character series such as FT or SKV carried over from a + * Fortnox or Bokio import), the letters configured as defaults, and the + * letters that already carry a name, so a freshly assigned series (L for + * löner, no voucher yet) can be named before its first verifikat. + * + * Only single-letter series can be named: those are the ones the pickers + * offer and the settings schema accepts. Imported multi-character series are + * listed with their highest number, as before, and cannot be picked for new + * vouchers anyway. The name is display only: it is what the pickers show + * next to the letter, with the Swedish preset as the fallback. The letter + * itself stays the identifier on every journal entry. + */ +export function VoucherSeriesManager({ settings, onSettingsUpdated }: VoucherSeriesManagerProps) { const t = useTranslations('settings_voucher_series') const { company } = useCompany() + const { toast } = useToast() const [series, setSeries] = useState([]) const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + + // The saved names, keyed by CONTENT rather than object identity. The + // settings hook revalidates on window focus and hands out a fresh object + // even when nothing changed; re-seeding the draft on that identity change + // would wipe whatever the user is typing. Serializing the filtered entries + // gives a key that only changes when a name actually changes. + const savedKey = useMemo(() => { + const entries: Array<[string, string]> = [] + for (const [letter, name] of Object.entries(settings.voucher_series_labels ?? {})) { + if (SERIES_LETTER_RE.test(letter) && typeof name === 'string' && name.trim()) { + entries.push([letter, name.trim()]) + } + } + entries.sort(([a], [b]) => a.localeCompare(b)) + return JSON.stringify(entries) + }, [settings.voucher_series_labels]) + const savedLabels = useMemo>( + () => Object.fromEntries(JSON.parse(savedKey) as Array<[string, string]>), + [savedKey], + ) + const [draft, setDraft] = useState>(savedLabels) + // Re-seed only when a saved name actually changed (another form on the + // page saved, or the settings were refetched with different content). + useEffect(() => { setDraft(savedLabels) }, [savedLabels]) + + const defaultSeries = settings.default_voucher_series || 'A' const fetchSeries = useCallback(async () => { if (!company?.id) { setIsLoading(false); return } @@ -38,41 +99,121 @@ export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProp useEffect(() => { fetchSeries() }, [fetchSeries]) - // Group by series letter, show the highest last_number - const grouped = series.reduce>((acc, s) => { - const existing = acc[s.voucher_series] || 0 - acc[s.voucher_series] = Math.max(existing, s.last_number) + // Highest last_number per series across fiscal periods. Every series the + // ledger holds counts, whatever its shape: the number is the only place in + // the UI that shows how far an imported series has run. + const lastNumbers = useMemo(() => { + const acc: Record = {} + for (const s of series) { + const key = s.voucher_series?.trim() + if (!key) continue + acc[key] = Math.max(acc[key] || 0, s.last_number) + } return acc - }, {}) + }, [series]) - const seriesEntries = Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)) + const rows = useMemo(() => { + const set = new Set(Object.keys(lastNumbers)) + const considerLetter = (value: unknown) => { + if (typeof value === 'string' && SERIES_LETTER_RE.test(value)) set.add(value) + } + considerLetter(defaultSeries) + Object.values(settings.default_voucher_series_per_source_type ?? {}).forEach(considerLetter) + Object.keys(savedLabels).forEach(considerLetter) + return Array.from(set).sort((a, b) => a.localeCompare(b)) + }, [lastNumbers, defaultSeries, settings.default_voucher_series_per_source_type, savedLabels]) + + const nameableLetters = useMemo(() => rows.filter((s) => SERIES_LETTER_RE.test(s)), [rows]) + + const hasChanges = nameableLetters.some( + (letter) => (draft[letter] ?? '').trim() !== (savedLabels[letter] ?? ''), + ) + + const handleSave = async () => { + setIsSaving(true) + try { + // Every nameable letter is sent, empty string meaning "clear this + // name"; the schema strips empties before storing. + const payload: Record = {} + for (const letter of nameableLetters) payload[letter] = (draft[letter] ?? '').trim() + const res = await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ voucher_series_labels: payload }), + }) + const json = await res.json() + if (!res.ok) { + toast({ + title: t('per_account_save_failed'), + description: getErrorMessage(json, { context: 'settings', statusCode: res.status }), + variant: 'destructive', + }) + return + } + const stored: Record = {} + for (const [letter, name] of Object.entries(payload)) if (name) stored[letter] = name + onSettingsUpdated({ voucher_series_labels: stored }) + toast({ title: t('names_saved_title'), description: t('names_saved_description') }) + } catch (err) { + toast({ + title: t('per_account_save_failed'), + description: getErrorMessage(err, { context: 'settings' }), + variant: 'destructive', + }) + } finally { + setIsSaving(false) + } + } return ( - + {isLoading ? (
- ) : seriesEntries.length === 0 ? ( -

- {t('empty_state', { series: defaultSeries || 'A' })} -

) : ( - seriesEntries.map(([letter, lastNum]) => ( -
- - {t('series_prefix')} {letter} - - {/* The default marker is a normal state: muted text, not a chip. */} - {letter === (defaultSeries || 'A') && ( - {t('default_badge')} - )} - - {t('latest_number')}: {lastNum} - + <> + {rows.map((seriesKey, i) => { + const lastNum = lastNumbers[seriesKey] + const nameable = SERIES_LETTER_RE.test(seriesKey) + // Placeholder shows the preset the name would fall back to, so an + // empty field never reads as "this series has no meaning". + const preset = nameable ? voucherSeriesLabel(seriesKey) : '' + return ( + + {nameable && ( + setDraft((prev) => ({ ...prev, [seriesKey]: e.target.value }))} + className="w-full md:w-64" + /> + )} + {seriesKey === defaultSeries && ( + {t('default_badge')} + )} + + {lastNum != null ? `${t('latest_number')}: ${lastNum}` : t('no_vouchers_yet')} + + + ) + })} +
+
- )) + )} ) diff --git a/components/settings/VoucherSeriesPerCashAccountForm.tsx b/components/settings/VoucherSeriesPerCashAccountForm.tsx index 01f9d983..4c1c36a4 100644 --- a/components/settings/VoucherSeriesPerCashAccountForm.tsx +++ b/components/settings/VoucherSeriesPerCashAccountForm.tsx @@ -7,18 +7,19 @@ import { useToast } from '@/components/ui/use-toast' import { SettingsGroup, SettingsRow, SettingsSelect } from '@/components/settings/SettingsRows' import { useCashAccounts } from '@/lib/reference-data/hooks' import { getErrorMessage } from '@/lib/errors/get-error-message' -import { VOUCHER_SERIES_PRESETS } from '@/lib/bookkeeping/voucher-series-resolver' +import { buildVoucherSeriesOptions } from '@/lib/bookkeeping/voucher-series-resolver' import type { CashAccount, CompanySettings } from '@/types' // Sentinel for "no override" in the