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