From bf7773d74b2955b48da5e781e08f90a02d4cf3d2 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sun, 6 Sep 2026 21:18:22 +0200 Subject: [PATCH] feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2184) A new company_settings row now defaults to the standard series set (A manual/bank, B kundfakturor, C inbetalningar, D leverantörsfakturor, E utbetalningar, H periodisering, I bokslut, K lön, L kontantfaktura, M moms) instead of everything on A. The set lives once, as the exhaustive STANDARD_VOUCHER_SERIES_MAP in the resolver; a pg-real test holds the column default equal to it and to the source_type CHECK. Existing rows are not remapped: the per-type settings form gets an "Använd standarduppsättningen" action that fills the set for review and save through the existing PUT, so the switch is a deliberate, audited act rather than a mid-year numbering change nobody decided. Payment rows bound to the other bokföringsmetod are dimmed, not hidden. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * test(bookkeeping): fresh company_settings row asserts the standard series set, not all-A voucher-series-defaults.pg.test.ts codified the pre-#2184 column default (every source type on A). Migration 20260906210500 replaces that default with the standard set, so the "freshly inserted row" case now asserts the representative letters and full equality with STANDARD_VOUCHER_SERIES_MAP. The explicit-override case keeps proving a company's own layout replaces the default wholesale. No other pg or tool test asserted on the old map. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- app/api/settings/__tests__/route.test.ts | 26 ++++++ .../VoucherSeriesPerSourceTypeForm.tsx | 92 ++++++++++++++----- lib/api/__tests__/schemas.test.ts | 9 ++ .../voucher-series-defaults.pg.test.ts | 27 ++++-- .../__tests__/voucher-series-resolver.test.ts | 81 ++++++++++++++++ lib/bookkeeping/voucher-series-resolver.ts | 81 +++++++++++++++- messages/en.json | 6 +- messages/sv.json | 6 +- public/docs/systemdokumentation-mall.md | 2 +- ...210500_voucher_series_standard_default.sql | 77 ++++++++++++++++ ...voucher-series-standard-default.pg.test.ts | 92 +++++++++++++++++++ 11 files changed, 465 insertions(+), 34 deletions(-) create mode 100644 supabase/migrations/20260906210500_voucher_series_standard_default.sql create mode 100644 tests/pg/voucher-series-standard-default.pg.test.ts diff --git a/app/api/settings/__tests__/route.test.ts b/app/api/settings/__tests__/route.test.ts index 6b99b643..8eb9039e 100644 --- a/app/api/settings/__tests__/route.test.ts +++ b/app/api/settings/__tests__/route.test.ts @@ -42,6 +42,7 @@ vi.mock('@/lib/tax/deadline-generator', async (importOriginal) => { import { PUT } from '../route' import { regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator' +import { STANDARD_VOUCHER_SERIES_MAP } from '@/lib/bookkeeping/voucher-series-resolver' describe('PUT /api/settings', () => { beforeEach(() => { @@ -123,6 +124,31 @@ describe('PUT /api/settings', () => { expect(findCall('company_settings', 'update')?.[0]).toEqual({ voucher_series_labels: { L: 'Lön' } }) }) + it('stores the standard voucher series set as sent (Använd standarduppsättningen)', async () => { + const standard = { ...STANDARD_VOUCHER_SERIES_MAP } + enqueueMany([ + { data: { entity_type: 'aktiebolag', onboarding_complete: true } }, // fetch oldSettings + { data: { id: 's1', default_voucher_series_per_source_type: standard } }, // update ... returning + { data: null, count: 5 }, // deadlines count + ]) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { default_voucher_series_per_source_type: standard }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ + data: { default_voucher_series_per_source_type: Record } + }>(response) + + expect(status).toBe(200) + expect(body.data.default_voucher_series_per_source_type).toEqual(standard) + // Every source type, storno and correction included, reaches the row unchanged. + expect(findCall('company_settings', 'update')?.[0]).toEqual({ + default_voucher_series_per_source_type: standard, + }) + }) + it('rejects a voucher_series_labels key that is not a single uppercase letter', async () => { const request = createMockRequest('/api/settings', { method: 'PUT', diff --git a/components/settings/VoucherSeriesPerSourceTypeForm.tsx b/components/settings/VoucherSeriesPerSourceTypeForm.tsx index 845bad67..88ff07c8 100644 --- a/components/settings/VoucherSeriesPerSourceTypeForm.tsx +++ b/components/settings/VoucherSeriesPerSourceTypeForm.tsx @@ -13,7 +13,12 @@ import { } from '@/components/settings/SettingsRows' import { cn } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' -import { buildVoucherSeriesOptions } from '@/lib/bookkeeping/voucher-series-resolver' +import { HelpPopover } from '@/components/ui/help-popover' +import { + buildVoucherSeriesOptions, + isStandardVoucherSeriesMap, + STANDARD_VOUCHER_SERIES_MAP, +} from '@/lib/bookkeeping/voucher-series-resolver' import type { CompanySettings, JournalEntrySourceType } from '@/types' // Subset of source_types presented to the user. The DB column accepts every @@ -43,6 +48,18 @@ const VISIBLE_SOURCE_TYPES: Array<{ key: JournalEntrySourceType; labelKey: strin // Keeps the map's iteration order intact: we only split it, never reorder. const ALWAYS_VISIBLE_COUNT = 3 +// The payment types that belong to one bokföringsmetod. Under the other +// method their rows are dimmed, never hidden (#2184): the choice stays visible +// and editable, so a company that switches method finds it already made and +// nothing in the map goes stale out of sight. Only the payment rows are +// bound to a method; registering an invoice happens under both. +const METHOD_BOUND: Partial> = { + invoice_paid: 'accrual', + supplier_invoice_paid: 'accrual', + invoice_cash_payment: 'cash', + supplier_invoice_cash_payment: 'cash', +} + // Swedish labels. Kept inline so this component is self-contained: these // labels are bookkeeping-domain terms that intentionally stay Swedish across // locales (see CLAUDE.md i18n table). @@ -73,6 +90,7 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }: // Generic fold labels ("Visa alla (n)" / "Visa färre") shared with the // dashboard widgets; the domain labels themselves stay hardcoded Swedish. const tCommon = useTranslations('dashboard') + const t = useTranslations('settings_bookkeeping') const { toast } = useToast() const initialMap = settings.default_voucher_series_per_source_type || {} const [draft, setDraft] = useState>>( @@ -80,6 +98,7 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }: ) const [isSaving, setIsSaving] = useState(false) const [showAll, setShowAll] = useState(false) + const method: 'accrual' | 'cash' = settings.accounting_method === 'cash' ? 'cash' : 'accrual' // Same closed list as the manual verifikat form and the per-bankkonto // picker: the fixed Swedish presets, then any letter already configured in @@ -111,6 +130,15 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }: const hasChanges = JSON.stringify(draft) !== JSON.stringify(initialMap) + // Fill the form with the set a new company starts with. Fills, never + // saves: a series switch mid-year is a deliberate act, so the fold opens to + // show every row it changes and the user commits with Spara serier. + const onStandardSet = isStandardVoucherSeriesMap(draft) + const handleUseStandardSet = () => { + setDraft({ ...STANDARD_VOUCHER_SERIES_MAP }) + setShowAll(true) + } + const handleSave = async () => { setIsSaving(true) try { @@ -151,27 +179,35 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }: const renderRow = ( { key, labelKey }: (typeof VISIBLE_SOURCE_TYPES)[number], borderless = false, - ) => ( - - handleChange(key, e.target.value)} - className="font-mono" + ) => { + const boundTo = METHOD_BOUND[key] + const dimmed = boundTo !== undefined && boundTo !== method + return ( + - {seriesOptions.map((option) => ( - - ))} - - - ) + handleChange(key, e.target.value)} + className="font-mono" + > + {seriesOptions.map((option) => ( + + ))} + + + ) + } const alwaysVisible = VISIBLE_SOURCE_TYPES.slice(0, ALWAYS_VISIBLE_COUNT) const folded = VISIBLE_SOURCE_TYPES.slice(ALWAYS_VISIBLE_COUNT) @@ -204,7 +240,19 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }: {folded.map((entry, i) => renderRow(entry, i === folded.length - 1))} -
+
+
+ + {t('series_standard_set_help')} +