feat(settings): let a company name each verifikationsserie letter (#2336)

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-06 16:20:42 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 0e3c0af841
commit 0c854ac54f
19 changed files with 633 additions and 89 deletions
+2
View File
@@ -1608,3 +1608,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+30 -1
View File
@@ -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<string, string> } }>(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
+8 -10
View File
@@ -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 */})
+7 -2
View File
@@ -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<ImportExecuteOptions>({
createFiscalPeriod: true,
@@ -394,9 +397,10 @@ export default function ImportReviewStep({
: isExisting
? ', används redan'
: ''
const name = voucherSeriesLabel(letter, seriesLabels)
return (
<SelectItem key={letter} value={letter}>
{`Serie ${letter}${suffix}`}
{`Serie ${letter}${name ? ` ${name}` : ''}${suffix}`}
</SelectItem>
)
})}
@@ -472,9 +476,10 @@ export default function ImportReviewStep({
: isExisting
? ', används redan'
: ''
const name = voucherSeriesLabel(letter, seriesLabels)
return (
<SelectItem key={letter} value={letter}>
{`Serie ${letter}${suffix}`}
{`Serie ${letter}${name ? ` ${name}` : ''}${suffix}`}
</SelectItem>
)
})}
+170 -29
View File
@@ -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<CompanySettings>) => 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<VoucherSeries[]>([])
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<Record<string, string>>(
() => Object.fromEntries(JSON.parse(savedKey) as Array<[string, string]>),
[savedKey],
)
const [draft, setDraft] = useState<Record<string, string>>(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<Record<string, number>>((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<string, number> = {}
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<string>(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<string, string> = {}
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<string, string> = {}
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 (
<SettingsGroup label={t('heading')} help={t('footnote')}>
<SettingsGroup label={t('heading')} help={`${t('name_help')} ${t('footnote')}`}>
{isLoading ? (
<div className="space-y-2 px-1 py-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
</div>
) : seriesEntries.length === 0 ? (
<p className="px-1 py-3 text-sm text-muted-foreground">
{t('empty_state', { series: defaultSeries || 'A' })}
</p>
) : (
seriesEntries.map(([letter, lastNum]) => (
<div key={letter} className="flex items-center gap-3 border-b border-border px-1 py-3">
<span className="text-sm font-medium tabular-nums">
{t('series_prefix')} {letter}
</span>
{/* The default marker is a normal state: muted text, not a chip. */}
{letter === (defaultSeries || 'A') && (
<SettingsRowNote>{t('default_badge')}</SettingsRowNote>
)}
<span className="ml-auto shrink-0 text-sm text-muted-foreground tabular-nums">
{t('latest_number')}: {lastNum}
</span>
<>
{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 (
<SettingsRow
key={seriesKey}
label={`${t('series_prefix')} ${seriesKey}`}
htmlFor={nameable ? `series-name-${seriesKey}` : undefined}
borderless={i === rows.length - 1}
>
{nameable && (
<SettingsInput
id={`series-name-${seriesKey}`}
value={draft[seriesKey] ?? ''}
maxLength={NAME_MAX_LENGTH}
placeholder={preset || t('name_placeholder')}
aria-label={`${t('name_column')} ${seriesKey}`}
onChange={(e) => setDraft((prev) => ({ ...prev, [seriesKey]: e.target.value }))}
className="w-full md:w-64"
/>
)}
{seriesKey === defaultSeries && (
<SettingsRowNote>{t('default_badge')}</SettingsRowNote>
)}
<span className="ml-auto shrink-0 text-sm text-muted-foreground tabular-nums">
{lastNum != null ? `${t('latest_number')}: ${lastNum}` : t('no_vouchers_yet')}
</span>
</SettingsRow>
)
})}
<div className="flex justify-end px-1 pt-4">
<Button type="button" size="sm" onClick={handleSave} disabled={!hasChanges || isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('save_names')}
</Button>
</div>
))
</>
)}
</SettingsGroup>
)
@@ -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 <select>: an empty option value renders
// as the placeholder in some browsers, so use an explicit token instead.
const FOLLOW_DEFAULT = '__default__'
const SERIES_LETTER_RE = /^[A-Z]$/
interface Props {
/** Company settings, for the letters the company has already configured. */
settings: Pick<CompanySettings, 'default_voucher_series' | 'default_voucher_series_per_source_type'>
/** Company settings, for the letters the company has already configured and named. */
settings: Pick<
CompanySettings,
'default_voucher_series' | 'default_voucher_series_per_source_type' | 'voucher_series_labels'
>
}
/** "Företagskort (1931)" or the bare ledger account when the row has no name. */
@@ -47,20 +48,21 @@ export function VoucherSeriesPerCashAccountForm({ settings }: Props) {
// Presets first, then any configured or already-assigned letter the presets
// do not cover, so a Select never renders blank on a value it does not offer.
const seriesOptions = useMemo(() => {
const preset = new Set(VOUCHER_SERIES_PRESETS.map((p) => p.letter))
const extras = [
// Names come from the company's own voucher_series_labels, presets as fallback.
const seriesOptions = useMemo(
() =>
buildVoucherSeriesOptions(settings.voucher_series_labels, [
settings.default_voucher_series,
...Object.values(settings.default_voucher_series_per_source_type ?? {}),
...cashAccounts.map((a) => a.voucher_series),
]),
[
settings.voucher_series_labels,
settings.default_voucher_series,
...Object.values(settings.default_voucher_series_per_source_type ?? {}),
...cashAccounts.map((a) => a.voucher_series),
]
.filter((v): v is string => typeof v === 'string' && SERIES_LETTER_RE.test(v) && !preset.has(v))
const uniqueExtras = Array.from(new Set(extras)).sort()
return [
...VOUCHER_SERIES_PRESETS,
...uniqueExtras.map((letter) => ({ letter, label: '' })),
]
}, [settings.default_voucher_series, settings.default_voucher_series_per_source_type, cashAccounts])
settings.default_voucher_series_per_source_type,
cashAccounts,
],
)
/** PATCH one account's override, then refresh the shared cash-account cache. */
const handleChange = async (account: CashAccount, value: string) => {
@@ -13,11 +13,9 @@ import {
} from '@/components/settings/SettingsRows'
import { cn } from '@/lib/utils'
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 { CompanySettings, JournalEntrySourceType } from '@/types'
const SERIES_LETTER_RE = /^[A-Z]$/
// Subset of source_types presented to the user. The DB column accepts every
// JournalEntrySourceType, but several values (storno, correction, etc.) are
// derived from the original entry's series and would surprise the user if
@@ -89,22 +87,22 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }:
// the draft so a non-preset letter stays selectable after the user changes
// that row away from it (otherwise a misclick could not be undone without
// a reload) and so a letter that lands in settings after mount is offered.
// A free A-Z list would let a typo start an undocumented series.
const seriesOptions = useMemo(() => {
const preset = new Set(VOUCHER_SERIES_PRESETS.map((p) => p.letter))
const extras = [
// A free A-Z list would let a typo start an undocumented series. Names
// come from the company's own voucher_series_labels, presets as fallback.
const seriesOptions = useMemo(
() =>
buildVoucherSeriesOptions(settings.voucher_series_labels, [
settings.default_voucher_series,
...Object.values(settings.default_voucher_series_per_source_type ?? {}),
...Object.values(draft),
]),
[
settings.voucher_series_labels,
settings.default_voucher_series,
...Object.values(settings.default_voucher_series_per_source_type ?? {}),
...Object.values(draft),
].filter(
(v): v is string => typeof v === 'string' && SERIES_LETTER_RE.test(v) && !preset.has(v),
)
const uniqueExtras = Array.from(new Set(extras)).sort()
return [
...VOUCHER_SERIES_PRESETS,
...uniqueExtras.map((letter) => ({ letter, label: '' })),
]
}, [settings.default_voucher_series, settings.default_voucher_series_per_source_type, draft])
settings.default_voucher_series_per_source_type,
draft,
],
)
const handleChange = (sourceType: JournalEntrySourceType, value: string) => {
setDraft((prev) => ({ ...prev, [sourceType]: value }))
@@ -11,7 +11,7 @@ import { FiscalYearsManager } from '@/components/settings/FiscalYearsManager'
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
import { VoucherSeriesPerSourceTypeForm } from '@/components/settings/VoucherSeriesPerSourceTypeForm'
import { VoucherSeriesPerCashAccountForm } from '@/components/settings/VoucherSeriesPerCashAccountForm'
import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolver'
import { applyDefaultSeriesToMap, voucherSeriesLabel } from '@/lib/bookkeeping/voucher-series-resolver'
import { DimensionsToggle } from '@/components/settings/DimensionsToggle'
import { MileageToggle } from '@/components/settings/MileageToggle'
import { SalesOrdersToggle } from '@/components/settings/SalesOrdersToggle'
@@ -152,11 +152,15 @@ export function BookkeepingSettingsContent() {
defaultValue={settings.default_voucher_series || 'A'}
className="font-mono"
>
{SERIES_OPTIONS.map((letter) => (
<option key={letter} value={letter}>
{letter}
</option>
))}
{SERIES_OPTIONS.map((letter) => {
// Same name the pickers show: the company's own, else the preset.
const label = voucherSeriesLabel(letter, settings.voucher_series_labels)
return (
<option key={letter} value={letter}>
{label ? `${letter} ${label}` : letter}
</option>
)
})}
</SettingsSelect>
</SettingsRow>
</SettingsGroup>
@@ -173,7 +177,7 @@ export function BookkeepingSettingsContent() {
<VoucherSeriesPerCashAccountForm settings={settings} />
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
<VoucherSeriesManager settings={settings} onSettingsUpdated={updateSettings} />
<SettingsGroup label={t('group_automation')}>
{/* Periodisering is a review-gated wizard step, not an automation
+42
View File
@@ -1956,6 +1956,48 @@ describe('UpdateSettingsSchema', () => {
expect(result.success).toBe(false)
})
})
describe('voucher_series_labels', () => {
it('accepts a map of letters to names and trims the names', () => {
const result = UpdateSettingsSchema.safeParse({
voucher_series_labels: { L: ' Lön ', N: 'Utlägg' },
})
expect(result.success).toBe(true)
expect(result.data?.voucher_series_labels).toEqual({ L: 'Lön', N: 'Utlägg' })
})
it('strips empty names so a cleared field removes the name', () => {
const result = UpdateSettingsSchema.safeParse({
voucher_series_labels: { L: 'Lön', K: '', M: ' ' },
})
expect(result.success).toBe(true)
expect(result.data?.voucher_series_labels).toEqual({ L: 'Lön' })
})
it('accepts an empty map', () => {
const result = UpdateSettingsSchema.safeParse({ voucher_series_labels: {} })
expect(result.success).toBe(true)
expect(result.data?.voucher_series_labels).toEqual({})
})
it('rejects keys that are not a single uppercase letter', () => {
expect(UpdateSettingsSchema.safeParse({ voucher_series_labels: { l: 'Lön' } }).success).toBe(false)
expect(UpdateSettingsSchema.safeParse({ voucher_series_labels: { AB: 'Lön' } }).success).toBe(false)
expect(UpdateSettingsSchema.safeParse({ voucher_series_labels: { '': 'Lön' } }).success).toBe(false)
})
it('rejects a name longer than 40 characters', () => {
const result = UpdateSettingsSchema.safeParse({
voucher_series_labels: { L: 'x'.repeat(41) },
})
expect(result.success).toBe(false)
})
it('rejects a non-string name', () => {
const result = UpdateSettingsSchema.safeParse({ voucher_series_labels: { L: 7 } })
expect(result.success).toBe(false)
})
})
})
// ============================================================
+14
View File
@@ -2394,6 +2394,20 @@ export const UpdateSettingsSchema = z.object({
z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z'),
)
.optional(),
// Company-defined display names per series letter ({"L": "Lön"}). Keys are
// single uppercase letters; values are trimmed to at most 40 characters. An
// empty value means "clear this name": it is stripped here so the stored
// map only ever holds real names and the resolver can treat a missing key
// as "use the preset". Display only; the engine never reads this column.
voucher_series_labels: z
.record(
z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z'),
z.string().trim().max(40, 'Serienamn får vara högst 40 tecken'),
)
.transform((labels) =>
Object.fromEntries(Object.entries(labels).filter(([, name]) => name.length > 0)),
)
.optional(),
// Invoice PDF settings
ore_rounding: z.boolean().optional(),
invoice_show_ocr: z.boolean().optional(),
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import {
applyDefaultSeriesToMap,
buildVoucherSeriesOptions,
formatVoucher,
parseVoucher,
resolveDefaultSeriesForSource,
@@ -232,4 +233,54 @@ describe('voucherSeriesLabel', () => {
expect(voucherSeriesLabel('Z')).toBe('')
expect(voucherSeriesLabel('')).toBe('')
})
it('lets the company name beat the preset, in place', () => {
// A byrå that runs löner on L (not the preset K) must see its own word.
expect(voucherSeriesLabel('L', { L: 'Lön' })).toBe('Lön')
expect(voucherSeriesLabel('L', { L: ' Lön ' })).toBe('Lön')
})
it('names a letter with no preset when the company has named it', () => {
expect(voucherSeriesLabel('N', { N: 'Utlägg' })).toBe('Utlägg')
})
it('falls back to the preset when the company name is empty or missing', () => {
expect(voucherSeriesLabel('K', { K: '' })).toBe('Lön')
expect(voucherSeriesLabel('K', { K: ' ' })).toBe('Lön')
expect(voucherSeriesLabel('K', {})).toBe('Lön')
expect(voucherSeriesLabel('K', null)).toBe('Lön')
expect(voucherSeriesLabel('N', { K: 'x' })).toBe('')
})
})
describe('buildVoucherSeriesOptions', () => {
it('offers the presets first, in their fixed order, with preset labels', () => {
const options = buildVoucherSeriesOptions(null, [])
expect(options.map((o) => o.letter).join('')).toBe('ABCDEFGHIJKLM')
expect(options[0]).toEqual({ letter: 'A', label: 'Redovisning' })
})
it('appends extra letters once, sorted, after the presets', () => {
const options = buildVoucherSeriesOptions(null, ['Z', 'N', 'N', 'A'])
expect(options.map((o) => o.letter).join('')).toBe('ABCDEFGHIJKLMNZ')
expect(options.find((o) => o.letter === 'N')).toEqual({ letter: 'N', label: '' })
})
it('drops anything that is not a single uppercase letter', () => {
const options = buildVoucherSeriesOptions(null, ['', null, undefined, 'ab', 'n', 7, ' '])
expect(options.map((o) => o.letter).join('')).toBe('ABCDEFGHIJKLM')
})
it('applies company names to presets in place and lists named non-preset letters', () => {
const options = buildVoucherSeriesOptions({ L: 'Lön', N: 'Utlägg' }, [])
expect(options.find((o) => o.letter === 'L')).toEqual({ letter: 'L', label: 'Lön' })
expect(options.find((o) => o.letter === 'K')).toEqual({ letter: 'K', label: 'Lön' })
expect(options.find((o) => o.letter === 'N')).toEqual({ letter: 'N', label: 'Utlägg' })
expect(options.map((o) => o.letter).join('')).toBe('ABCDEFGHIJKLMN')
})
it('ignores malformed keys in the label map', () => {
const options = buildVoucherSeriesOptions({ ab: 'x', n: 'y', '': 'z' }, [])
expect(options.map((o) => o.letter).join('')).toBe('ABCDEFGHIJKLM')
})
})
+52 -2
View File
@@ -59,12 +59,62 @@ export const VOUCHER_SERIES_PRESETS: ReadonlyArray<{ letter: string; label: stri
{ letter: 'M', label: 'Momsrapport' },
]
/** Swedish description for a preset series letter; empty for unknown letters. */
export function voucherSeriesLabel(letter: string): string {
/**
* Company-defined series names, company_settings.voucher_series_labels:
* {"L": "Lön"}. Keys are single uppercase letters, values non-empty names.
*/
export type VoucherSeriesLabels = Partial<Record<string, string>>
/**
* Display name for a series letter: the company's own name first, the Swedish
* preset second, empty when neither exists. The ONLY place that decides what a
* letter is called; every picker and list goes through it so a company that
* lays its series out differently from the presets (L for löner instead of K)
* sees its own words everywhere, not Fortnox's.
*/
export function voucherSeriesLabel(
letter: string,
labels?: VoucherSeriesLabels | null,
): string {
const custom = labels?.[letter]
if (typeof custom === 'string' && custom.trim().length > 0) return custom.trim()
const match = VOUCHER_SERIES_PRESETS.find((p) => p.letter === letter)
return match ? match.label : ''
}
/**
* The closed list every series picker offers: the presets in their fixed
* order, then every other letter the company already uses or has named,
* deduplicated and sorted. Each entry carries the display name resolved by
* voucherSeriesLabel, so a custom name overrides a preset in place.
*
* `extraLetters` may hold anything (settings values, draft state, account
* rows); only single uppercase letters survive, so an empty string, null or a
* typo never becomes an option. A free A-Z list would let a slip start an
* undocumented series (BFNAR 2013:2 p. 9.2-9.15 wants the series in use
* enumerated in the systemdokumentation).
*/
export function buildVoucherSeriesOptions(
labels: VoucherSeriesLabels | null | undefined,
extraLetters: Iterable<unknown>,
): Array<{ letter: string; label: string }> {
const seen = new Set(VOUCHER_SERIES_PRESETS.map((p) => p.letter))
const extras = new Set<string>()
const consider = (value: unknown) => {
if (typeof value === 'string' && SERIES_LETTER_RE.test(value) && !seen.has(value)) {
extras.add(value)
}
}
for (const value of extraLetters) consider(value)
for (const key of Object.keys(labels ?? {})) consider(key)
return [
...VOUCHER_SERIES_PRESETS.map((p) => ({ letter: p.letter, label: voucherSeriesLabel(p.letter, labels) })),
...Array.from(extras)
.sort()
.map((letter) => ({ letter, label: voucherSeriesLabel(letter, labels) })),
]
}
/**
* 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
+8 -1
View File
@@ -2378,7 +2378,14 @@
"per_account_saved_title": "Voucher series saved",
"per_account_saved_set": "New vouchers from {account} land in series {series}.",
"per_account_saved_cleared": "{account} now follows the default bank-transaction series.",
"per_account_save_failed": "Could not save"
"per_account_save_failed": "Could not save",
"name_column": "Name",
"name_placeholder": "Name (optional)",
"name_help": "The name is shown next to the letter wherever you pick a series, for example in the voucher form. Leave it empty to show the default description.",
"no_vouchers_yet": "No vouchers yet",
"save_names": "Save names",
"names_saved_title": "Series names saved",
"names_saved_description": "The names now appear in the series pickers."
},
"settings_team_panel": {
"role_owner": "Owner",
+8 -1
View File
@@ -2378,7 +2378,14 @@
"per_account_saved_title": "Verifikationsserie sparad",
"per_account_saved_set": "Nya verifikat från {account} hamnar i serie {series}.",
"per_account_saved_cleared": "{account} följer nu standardserien för banktransaktioner.",
"per_account_save_failed": "Kunde inte spara"
"per_account_save_failed": "Kunde inte spara",
"name_column": "Namn",
"name_placeholder": "Namn (valfritt)",
"name_help": "Namnet visas bredvid bokstaven överallt där du väljer serie, till exempel i verifikatfönstret. Tomt fält visar standardbeskrivningen.",
"no_vouchers_yet": "Inga verifikat ännu",
"save_names": "Spara namn",
"names_saved_title": "Serienamn sparade",
"names_saved_description": "Namnen visas nu i serieväljarna."
},
"settings_team_panel": {
"role_owner": "Ägare",
@@ -0,0 +1,28 @@
-- Migration: Company-defined names for verifikationsserier
--
-- The series pickers show a fixed Swedish preset label next to each letter
-- (A Redovisning, B Kundfakturor, ...). Those presets follow Fortnox's
-- layout; a byrå that lays its series out differently (L for löner instead
-- of K, say) sees a wrong or missing name in every dropdown. This column
-- lets a company name each letter itself. Display only: the booking engine
-- never reads it, and the letter stays the identifier on journal_entries.
--
-- Keys are single uppercase letters A-Z, values trimmed strings of 1 to 40
-- characters. Validated by UpdateSettingsSchema (lib/api/schemas.ts); the
-- CHECK below only guards the JSON shape so a raw write cannot store an
-- array or scalar that the resolver would then choke on.
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS voucher_series_labels JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE public.company_settings
DROP CONSTRAINT IF EXISTS company_settings_voucher_series_labels_object;
ALTER TABLE public.company_settings
ADD CONSTRAINT company_settings_voucher_series_labels_object
CHECK (jsonb_typeof(voucher_series_labels) = 'object');
COMMENT ON COLUMN public.company_settings.voucher_series_labels IS
'Company-defined display names per verifikationsserie letter: {"L": "Lön"}. Keys A-Z, values 1-40 chars. Falls back to VOUCHER_SERIES_PRESETS in lib/bookkeeping/voucher-series-resolver.ts. Display only; never read by the booking engine.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,44 @@
-- Migration: DB-level shape rules for company_settings.voucher_series_labels
--
-- 20260906131300 added the column with a CHECK that it is a JSON object; the
-- key and value rules (single uppercase letter A-Z, name of 1 to 40 characters)
-- lived only in UpdateSettingsSchema. A write that bypasses /api/settings (an
-- admin script, a backfill, a future service-role path) could therefore store
-- a map the pickers were never designed for. This mirrors the Zod rules in
-- the database so processing integrity does not depend on one route.
--
-- The function is IMMUTABLE and STRICT so it can back a CHECK constraint; a
-- NULL map never reaches it because the column is NOT NULL.
CREATE OR REPLACE FUNCTION public.voucher_series_labels_valid(labels JSONB)
RETURNS BOOLEAN
LANGUAGE sql
IMMUTABLE
STRICT
SET search_path = ''
AS $$
SELECT jsonb_typeof(labels) = 'object'
AND NOT EXISTS (
SELECT 1
FROM jsonb_each(labels) AS entry(key, value)
WHERE entry.key !~ '^[A-Z]$'
OR jsonb_typeof(entry.value) <> 'string'
OR length(btrim(entry.value #>> '{}')) < 1
OR length(entry.value #>> '{}') > 40
);
$$;
COMMENT ON FUNCTION public.voucher_series_labels_valid(JSONB) IS
'Shape rule for company_settings.voucher_series_labels: object whose keys are single uppercase letters and whose values are non-blank strings of at most 40 characters. Mirrors UpdateSettingsSchema.';
ALTER TABLE public.company_settings
DROP CONSTRAINT IF EXISTS company_settings_voucher_series_labels_object;
ALTER TABLE public.company_settings
DROP CONSTRAINT IF EXISTS company_settings_voucher_series_labels_valid;
ALTER TABLE public.company_settings
ADD CONSTRAINT company_settings_voucher_series_labels_valid
CHECK (public.voucher_series_labels_valid(voucher_series_labels));
NOTIFY pgrst, 'reload schema';
+1
View File
@@ -522,6 +522,7 @@ export function makeCompanySettings(
storno: 'A',
correction: 'A',
},
voucher_series_labels: {},
last_supplier_payment_account: null,
ore_rounding: true,
invoice_show_ocr: true,
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest'
import { getPool } from './setup'
import { seedCompany } from './fixtures'
/**
* `company_settings.voucher_series_labels` shape rule (migrations
* 20260906131300 and 20260906134700).
*
* The API route validates the map through UpdateSettingsSchema; this CHECK
* is what stops a write that bypasses the route (admin script, backfill,
* service-role path) from storing a map the series pickers cannot handle.
* Under test: `voucher_series_labels_valid(jsonb)` and the constraint that
* wraps it. Superuser pool on purpose: the object is the CHECK, not RLS.
*/
const CHECK_VIOLATION = '23514'
async function seedSettings(): Promise<string> {
const { companyId } = await seedCompany()
// A trigger may already have created the row; either way one exists after this.
await getPool().query(
`INSERT INTO public.company_settings (company_id) VALUES ($1) ON CONFLICT DO NOTHING`,
[companyId],
)
return companyId
}
async function setLabels(companyId: string, labels: string): Promise<void> {
await getPool().query(
`UPDATE public.company_settings SET voucher_series_labels = $2::jsonb WHERE company_id = $1`,
[companyId, labels],
)
}
async function expectRejected(companyId: string, labels: string): Promise<void> {
await expect(setLabels(companyId, labels)).rejects.toMatchObject({ code: CHECK_VIOLATION })
}
describe('voucher_series_labels_valid', () => {
it('accepts the empty map, single-letter keys and names of 1 to 40 characters', async () => {
const { rows } = await getPool().query<{ empty: boolean; good: boolean; max: boolean }>(
`SELECT public.voucher_series_labels_valid('{}'::jsonb) AS empty,
public.voucher_series_labels_valid('{"L":"Lön","N":"Utlägg"}'::jsonb) AS good,
public.voucher_series_labels_valid(('{"L":"' || repeat('x', 40) || '"}')::jsonb) AS max`,
)
expect(rows[0]).toEqual({ empty: true, good: true, max: true })
})
it('rejects every shape the Zod schema rejects', async () => {
const { rows } = await getPool().query<Record<string, boolean>>(
`SELECT public.voucher_series_labels_valid('{"l":"Lön"}'::jsonb) AS lowercase_key,
public.voucher_series_labels_valid('{"AB":"Lön"}'::jsonb) AS two_letter_key,
public.voucher_series_labels_valid('{"":"Lön"}'::jsonb) AS empty_key,
public.voucher_series_labels_valid('{"L":""}'::jsonb) AS empty_value,
public.voucher_series_labels_valid('{"L":" "}'::jsonb) AS blank_value,
public.voucher_series_labels_valid(('{"L":"' || repeat('x', 41) || '"}')::jsonb) AS too_long,
public.voucher_series_labels_valid('{"L":7}'::jsonb) AS number_value,
public.voucher_series_labels_valid('{"L":null}'::jsonb) AS null_value,
public.voucher_series_labels_valid('[]'::jsonb) AS array_map,
public.voucher_series_labels_valid('"Lön"'::jsonb) AS scalar`,
)
expect(Object.values(rows[0]).every((v) => v === false)).toBe(true)
})
})
describe('company_settings.voucher_series_labels CHECK', () => {
it('defaults to an empty map and stores a valid map', async () => {
const companyId = await seedSettings()
const before = await getPool().query<{ voucher_series_labels: unknown }>(
`SELECT voucher_series_labels FROM public.company_settings WHERE company_id = $1`,
[companyId],
)
expect(before.rows[0].voucher_series_labels).toEqual({})
await setLabels(companyId, '{"L":"Lön","N":"Utlägg"}')
const after = await getPool().query<{ voucher_series_labels: unknown }>(
`SELECT voucher_series_labels FROM public.company_settings WHERE company_id = $1`,
[companyId],
)
expect(after.rows[0].voucher_series_labels).toEqual({ L: 'Lön', N: 'Utlägg' })
})
it('refuses a lowercase or multi-letter key', async () => {
const companyId = await seedSettings()
await expectRejected(companyId, '{"l":"Lön"}')
await expectRejected(companyId, '{"FT":"Fortnox"}')
})
it('refuses a blank, over-long or non-string name', async () => {
const companyId = await seedSettings()
await expectRejected(companyId, '{"L":""}')
await expectRejected(companyId, '{"L":" "}')
await expectRejected(companyId, `{"L":"${'x'.repeat(41)}"}`)
await expectRejected(companyId, '{"L":7}')
})
it('refuses anything that is not an object', async () => {
const companyId = await seedSettings()
await expectRejected(companyId, '[]')
await expectRejected(companyId, '"Lön"')
})
it('leaves the row untouched after a refused write', async () => {
const companyId = await seedSettings()
await setLabels(companyId, '{"L":"Lön"}')
await expectRejected(companyId, '{"l":"fel"}')
const { rows } = await getPool().query<{ voucher_series_labels: unknown }>(
`SELECT voucher_series_labels FROM public.company_settings WHERE company_id = $1`,
[companyId],
)
expect(rows[0].voucher_series_labels).toEqual({ L: 'Lön' })
})
})
+8
View File
@@ -488,6 +488,14 @@ export interface CompanySettings {
* settings UI.
*/
default_voucher_series_per_source_type: Partial<Record<JournalEntrySourceType, string>>
/**
* Company-defined display names per series letter ({"L": "Lön"}). Keys are
* single uppercase letters A-Z, values 1 to 40 characters. Resolved by
* `voucherSeriesLabel()` in `lib/bookkeeping/voucher-series-resolver.ts`,
* which falls back to the Swedish presets. Display only: the booking
* engine never reads it.
*/
voucher_series_labels: Partial<Record<string, string>>
// Most recently picked BAS account for supplier invoice payments: used to
// default the mark-paid dialog so repeat payments don't force re-picking.