feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2358)

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:18:22 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 4fce2d7b94
commit bf7773d74b
11 changed files with 465 additions and 34 deletions
+26
View File
@@ -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<string, string> }
}>(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',
@@ -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<Record<JournalEntrySourceType, 'accrual' | 'cash'>> = {
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<Partial<Record<JournalEntrySourceType, string>>>(
@@ -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,
) => (
<SettingsRow
key={key}
label={SV_LABELS[labelKey] ?? key}
htmlFor={`series-${key}`}
borderless={borderless}
>
<SettingsSelect
id={`series-${key}`}
value={(draft[key] as string | undefined) || 'A'}
onChange={(e) => handleChange(key, e.target.value)}
className="font-mono"
) => {
const boundTo = METHOD_BOUND[key]
const dimmed = boundTo !== undefined && boundTo !== method
return (
<SettingsRow
key={key}
label={SV_LABELS[labelKey] ?? key}
htmlFor={`series-${key}`}
help={
dimmed ? t(boundTo === 'cash' ? 'series_row_cash_only' : 'series_row_accrual_only') : undefined
}
borderless={borderless}
className={dimmed ? 'opacity-60' : undefined}
>
{seriesOptions.map((option) => (
<option key={option.letter} value={option.letter}>
{option.label ? `${option.letter} ${option.label}` : option.letter}
</option>
))}
</SettingsSelect>
</SettingsRow>
)
<SettingsSelect
id={`series-${key}`}
value={(draft[key] as string | undefined) || 'A'}
onChange={(e) => handleChange(key, e.target.value)}
className="font-mono"
>
{seriesOptions.map((option) => (
<option key={option.letter} value={option.letter}>
{option.label ? `${option.letter} ${option.label}` : option.letter}
</option>
))}
</SettingsSelect>
</SettingsRow>
)
}
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))}
</SettingsReveal>
<div className="flex justify-end px-1 pt-4">
<div className="flex flex-wrap items-center justify-between gap-3 px-1 pt-4">
<div className="flex items-center gap-1.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleUseStandardSet}
disabled={onStandardSet || isSaving}
>
{t('series_standard_set_button')}
</Button>
<HelpPopover className="shrink-0">{t('series_standard_set_help')}</HelpPopover>
</div>
<Button
type="button"
size="sm"
+9
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { HouseworkTypeSchema } from '../schemas'
import { STANDARD_VOUCHER_SERIES_MAP } from '@/lib/bookkeeping/voucher-series-resolver'
import {
// Enums
EntityTypeSchema,
@@ -1955,6 +1956,14 @@ describe('UpdateSettingsSchema', () => {
})
expect(result.success).toBe(false)
})
it('accepts the full standard set, which the settings action sends with every source type', () => {
const result = UpdateSettingsSchema.safeParse({
default_voucher_series_per_source_type: { ...STANDARD_VOUCHER_SERIES_MAP },
})
expect(result.success).toBe(true)
expect(result.data?.default_voucher_series_per_source_type).toEqual(STANDARD_VOUCHER_SERIES_MAP)
})
})
describe('voucher_series_labels', () => {
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { insertAuthUser, insertCompany, insertCompanyMember } from '@/tests/pg/fixtures'
import { STANDARD_VOUCHER_SERIES_MAP } from '@/lib/bookkeeping/voucher-series-resolver'
describe('company_settings.default_voucher_series_per_source_type', () => {
it('column exists with the expected default JSONB shape', async () => {
@@ -23,7 +24,12 @@ describe('company_settings.default_voucher_series_per_source_type', () => {
expect(defaultValue).toMatch(/salary_payment/)
})
it('a freshly inserted company_settings row gets all-A defaults', async () => {
// Since migration 20260906210500 (#2184) a fresh row starts on the standard
// set, not on all-A; the exhaustive equality with the TS map and the
// "existing rows untouched" rule live in
// tests/pg/voucher-series-standard-default.pg.test.ts. This test keeps the
// representative letters readable at a glance.
it('a freshly inserted company_settings row gets the standard series set', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
@@ -47,10 +53,16 @@ describe('company_settings.default_voucher_series_per_source_type', () => {
expect(rows).toHaveLength(1)
const map = rows[0]!.default_voucher_series_per_source_type
expect(map).toBeDefined()
// A stays the general series; the reskontra, lön and moms flows get their own.
expect(map.manual).toBe('A')
expect(map.supplier_invoice_registered).toBe('A')
expect(map.salary_payment).toBe('A')
expect(map.bank_transaction).toBe('A')
expect(map.invoice_created).toBe('B')
expect(map.invoice_paid).toBe('C')
expect(map.supplier_invoice_registered).toBe('D')
expect(map.supplier_invoice_paid).toBe('E')
expect(map.salary_payment).toBe('K')
expect(map.vat_settlement).toBe('M')
expect(map).toEqual(STANDARD_VOUCHER_SERIES_MAP)
})
it('accepts user updates to per-source-type series mapping', async () => {
@@ -65,12 +77,13 @@ describe('company_settings.default_voucher_series_per_source_type', () => {
[userId, companyId],
)
// Configure per common Swedish convention: B for supplier, C for salary.
// An explicit layout of the company's own (Björn Lundén style letters)
// replaces the default wholesale; nothing merges the standard set back in.
const updated = {
manual: 'A',
supplier_invoice_registered: 'B',
supplier_invoice_paid: 'B',
salary_payment: 'C',
supplier_invoice_registered: 'L',
supplier_invoice_paid: 'U',
salary_payment: 'N',
}
await getPool().query(
`UPDATE public.company_settings
@@ -3,11 +3,15 @@ import {
applyDefaultSeriesToMap,
buildVoucherSeriesOptions,
formatVoucher,
isStandardVoucherSeriesMap,
parseVoucher,
resolveDefaultSeriesForSource,
STANDARD_VOUCHER_SERIES_MAP,
voucherSeriesLabel,
VOUCHER_SERIES_PRESETS,
} from '../voucher-series-resolver'
import { JournalEntrySourceTypeSchema } from '@/lib/api/schemas'
import type { JournalEntrySourceType } from '@/types'
describe('resolveDefaultSeriesForSource', () => {
it('returns A when settings is null', () => {
@@ -284,3 +288,80 @@ describe('buildVoucherSeriesOptions', () => {
expect(options.map((o) => o.letter).join('')).toBe('ABCDEFGHIJKLM')
})
})
describe('STANDARD_VOUCHER_SERIES_MAP', () => {
const sourceTypes = JournalEntrySourceTypeSchema.options
const legacyAllA = Object.fromEntries(sourceTypes.map((type) => [type, 'A']))
it('assigns a letter to every journal source type, so a new type cannot fall back to A unnoticed', () => {
expect(Object.keys(STANDARD_VOUCHER_SERIES_MAP).sort()).toEqual([...sourceTypes].sort())
})
it('only uses letters the presets name, so every series in the set has a name in the pickers', () => {
for (const letter of new Set(Object.values(STANDARD_VOUCHER_SERIES_MAP))) {
expect(letter).toMatch(/^[A-Z]$/)
expect(voucherSeriesLabel(letter)).not.toBe('')
}
})
it('keeps manual entries and bank transactions in A, the general series', () => {
expect(STANDARD_VOUCHER_SERIES_MAP.manual).toBe('A')
expect(STANDARD_VOUCHER_SERIES_MAP.bank_transaction).toBe('A')
})
it('separates the reskontra, lön, moms, periodisering and bokslut flows', () => {
expect(STANDARD_VOUCHER_SERIES_MAP.invoice_created).toBe('B')
expect(STANDARD_VOUCHER_SERIES_MAP.invoice_paid).toBe('C')
expect(STANDARD_VOUCHER_SERIES_MAP.invoice_cash_payment).toBe('C')
expect(STANDARD_VOUCHER_SERIES_MAP.supplier_invoice_registered).toBe('D')
expect(STANDARD_VOUCHER_SERIES_MAP.supplier_invoice_paid).toBe('E')
expect(STANDARD_VOUCHER_SERIES_MAP.supplier_invoice_cash_payment).toBe('E')
expect(STANDARD_VOUCHER_SERIES_MAP.accrual).toBe('H')
expect(STANDARD_VOUCHER_SERIES_MAP.year_end).toBe('I')
expect(STANDARD_VOUCHER_SERIES_MAP.salary_payment).toBe('K')
expect(STANDARD_VOUCHER_SERIES_MAP.vat_settlement).toBe('M')
})
it('books each type of a fresh company in its series, and an explicit override still wins', () => {
const fresh = { default_voucher_series_per_source_type: { ...STANDARD_VOUCHER_SERIES_MAP } }
expect(resolveDefaultSeriesForSource(fresh, 'invoice_created')).toBe('B')
expect(resolveDefaultSeriesForSource(fresh, 'salary_payment')).toBe('K')
expect(resolveDefaultSeriesForSource(fresh, 'manual')).toBe('A')
const overridden = {
default_voucher_series_per_source_type: { ...STANDARD_VOUCHER_SERIES_MAP, invoice_created: 'F' },
}
expect(resolveDefaultSeriesForSource(overridden, 'invoice_created')).toBe('F')
})
it('never reaches an existing company: the resolver reads the row, not the standard set', () => {
const existing = { default_voucher_series_per_source_type: legacyAllA }
for (const type of sourceTypes) {
expect(resolveDefaultSeriesForSource(existing, type)).toBe('A')
}
// A row that predates a source type keeps falling back to A for it too.
expect(
resolveDefaultSeriesForSource(
{ default_voucher_series_per_source_type: { manual: 'A' } },
'expense_payout',
),
).toBe('A')
})
})
describe('isStandardVoucherSeriesMap', () => {
it('is true for the standard set and for a superset that adds an unknown key', () => {
expect(isStandardVoucherSeriesMap({ ...STANDARD_VOUCHER_SERIES_MAP })).toBe(true)
expect(isStandardVoucherSeriesMap({ ...STANDARD_VOUCHER_SERIES_MAP, future_type: 'Q' })).toBe(true)
})
it('is false when one type deviates, when a type is missing, and for no map', () => {
expect(isStandardVoucherSeriesMap({ ...STANDARD_VOUCHER_SERIES_MAP, salary_payment: 'L' })).toBe(false)
const partial: Partial<Record<JournalEntrySourceType, string>> = { ...STANDARD_VOUCHER_SERIES_MAP }
delete partial.storno
expect(isStandardVoucherSeriesMap(partial as Record<string, string>)).toBe(false)
expect(isStandardVoucherSeriesMap(null)).toBe(false)
expect(isStandardVoucherSeriesMap(undefined)).toBe(false)
expect(isStandardVoucherSeriesMap({})).toBe(false)
})
})
+79 -2
View File
@@ -37,8 +37,8 @@ const SERIES_LETTER_RE = /^[A-Z]$/
* the incumbents disagree with each other: Björn Lundén uses F for
* kundfakturor, L for leverantörsfakturor and N for löner. The one point
* they agree on is that A is the general series you post manual entries into,
* which also matches this codebase: every source_type in
* company_settings.default_voucher_series_per_source_type ships as 'A'.
* which is where STANDARD_VOUCHER_SERIES_MAP below keeps everything that is
* not a reskontra, lön, moms, periodisering or bokslut flow.
*
* The labels are bookkeeping-domain terms that stay Swedish in both locales,
* same convention as VoucherSeriesPerSourceTypeForm.
@@ -115,6 +115,83 @@ export function buildVoucherSeriesOptions(
]
}
/**
* The series layout a new company starts with, and what "Använd
* standarduppsättningen" under Inställningar > Bokföring fills in for an
* existing one. The DB column default (migration 20260906210500) is this map
* verbatim; tests/pg/voucher-series-standard-default.pg.test.ts holds the two
* equal.
*
* The letters are the presets above, so every series in the set already has
* a name in the pickers and a ledger imported from Fortnox continues its
* kundfakturor in B, leverantörsfakturor in D and löner in K instead of
* starting parallel series next to them. The principle: the flows a reader
* of the verifikationslista wants to see apart (kundfakturor, inbetalningar,
* leverantörsfakturor, utbetalningar, periodisering, bokslut, lön, moms) get
* their own series; everything else stays in A, the general series.
*
* Exhaustive over JournalEntrySourceType on purpose. Before this map the
* column default was "everything on A" and the resolver's 'A' fallback let
* every source type added since (webshop_order, vat_settlement,
* expense_payout, ...) join that series without anyone deciding; a new
* source type now fails to compile until it has a letter here.
*
* resolveDefaultSeriesForSource never reads this map. A company whose row
* predates it keeps 'A' for every type until it applies the set itself:
* remapping a live ledger by migration would move, say, the next kundfaktura
* from A341 into a fresh B1 mid-year with nothing in the behandlingshistorik
* saying who decided it. BFNAR 2013:2 p. 9.16 records changes to the
* behandlingsregler with date and actor; the settings save gives that, a
* migration cannot.
*/
export const STANDARD_VOUCHER_SERIES_MAP: Readonly<Record<JournalEntrySourceType, string>> = {
manual: 'A',
bank_transaction: 'A',
invoice_created: 'B',
credit_note: 'B',
reminder_fee: 'B',
invoice_paid: 'C',
invoice_cash_payment: 'C',
rot_rut_payout: 'C',
supplier_invoice_registered: 'D',
supplier_credit_note: 'D',
supplier_invoice_privately_paid: 'D',
supplier_invoice_paid: 'E',
supplier_invoice_cash_payment: 'E',
accrual: 'H',
year_end: 'I',
result_appropriation: 'I',
salary_payment: 'K',
webshop_order: 'L',
vat_settlement: 'M',
opening_balance: 'A',
currency_revaluation: 'A',
inbox_item: 'A',
import: 'A',
system: 'A',
storno: 'A',
correction: 'A',
stripe_payout: 'A',
expense_claim: 'A',
expense_payout: 'A',
}
/**
* True when `map` assigns every source type exactly the standard letter.
* The settings form uses it to disable "Använd standarduppsättningen" once
* the draft already is the standard set. Extra keys are ignored: a map that
* carries a letter for a source type this build does not know is still
* "on the standard set" for every type it can book.
*/
export function isStandardVoucherSeriesMap(
map: VoucherSeriesMap | null | undefined,
): boolean {
if (!map || typeof map !== 'object') return false
return (Object.keys(STANDARD_VOUCHER_SERIES_MAP) as JournalEntrySourceType[]).every(
(sourceType) => map[sourceType] === STANDARD_VOUCHER_SERIES_MAP[sourceType],
)
}
/**
* 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
+5 -1
View File
@@ -1890,7 +1890,11 @@
"related_chart_of_accounts": "Chart of accounts (BAS)",
"periodisering_label": "Accruals",
"periodisering_help": "The year-end wizard scans invoices for date ranges extending into the next fiscal year and suggests accruals. Nothing is booked without your review.",
"periodisering_open_wizard": "Open the accruals wizard"
"periodisering_open_wizard": "Open the accruals wizard",
"series_standard_set_button": "Use the standard set",
"series_standard_set_help": "Fills in the set new companies start with: customer invoices B, customer payments C, supplier invoices D, supplier payments E, accruals H, year-end I, salaries K, cash invoices L, VAT settlement M, everything else A. Review the rows and save. Preferably switch at the start of a fiscal year: vouchers already posted stay in their series, new ones land in the new series from the moment you save, and the change shows in the processing history.",
"series_row_accrual_only": "Applies to the invoice method (faktureringsmetoden), which this company does not use. The choice is saved and applies if the method changes.",
"series_row_cash_only": "Applies to the cash method (kontantmetoden), which this company does not use. The choice is saved and applies if the method changes."
},
"settings_tax": {},
"settings_team": {},
+5 -1
View File
@@ -1890,7 +1890,11 @@
"related_chart_of_accounts": "Kontoplan (BAS)",
"periodisering_label": "Periodisering",
"periodisering_help": "I bokslut-wizarden skannas fakturor efter datumintervall som sträcker sig in i nästa räkenskapsår och periodiseringar föreslås. Inget bokförs utan din granskning.",
"periodisering_open_wizard": "Öppna periodiserings-wizarden"
"periodisering_open_wizard": "Öppna periodiserings-wizarden",
"series_standard_set_button": "Använd standarduppsättningen",
"series_standard_set_help": "Fyller i uppsättningen som nya företag får: kundfakturor B, inbetalningar C, leverantörsfakturor D, utbetalningar E, periodiseringar H, bokslut I, lön K, kontantfakturor L, momsredovisning M, övrigt A. Granska raderna och spara. Byt helst vid ett räkenskapsårs början: redan bokförda verifikationer ligger kvar i sina serier, nya hamnar i de nya serierna från och med att du sparar, och bytet syns i behandlingshistoriken.",
"series_row_accrual_only": "Gäller faktureringsmetoden, som företaget inte använder. Valet sparas och gäller om metoden byts.",
"series_row_cash_only": "Gäller kontantmetoden, som företaget inte använder. Valet sparas och gäller om metoden byts."
},
"settings_tax": {},
"settings_team": {},
+1 -1
View File
@@ -136,7 +136,7 @@ Bankkonto 1930 avstäms via bankavstämningsmodulen (flerstegs matchning: exakt
Verifikationsnummer tilldelas sekventiellt av systemet vid bokföring. Numreringen är unik per företag, räkenskapsår och verifikationsserie. Numren tilldelas via en databasfunktion som är säker vid samtidiga anrop och kan inte sättas manuellt.
Systemet stödjer flera verifikationsserier. Standardserien är A. Vilken serie som används kan styras per underlagstyp (t.ex. egen serie för löner) under **Inställningar > Bokföring**.
Systemet stödjer flera verifikationsserier. Nya företag får standarduppsättningen: A för manuella verifikationer och banktransaktioner, B kundfakturor, C inbetalningar från kunder, D leverantörsfakturor, E utbetalningar till leverantörer, H periodiseringar, I bokslut, K lön, L kontantfakturor och M momsredovisning. Företag upplagda före september 2026 har alla verifikationer i serie A om inget annat valts. Vilken serie som används kan styras per underlagstyp under **Inställningar > Bokföring**, där standarduppsättningen också kan väljas i efterhand; ett byte gäller nya verifikationer och görs lämpligen vid ett räkenskapsårs början.
Verifikationsserier som används i detta företag: [ANGE, t.ex. "Endast serie A" eller "A för löpande bokföring, L för löner"]
@@ -0,0 +1,77 @@
-- Migration: Standard verifikationsserier for new companies (issue #2184)
--
-- default_voucher_series_per_source_type shipped (20260526120700) with every
-- source type on 'A' "to preserve current behaviour", and nothing since has
-- asked a new company to lay its series out: the onboarding upsert
-- (lib/company/create-company.ts) and the sandbox seed both insert the row
-- without this column, so every company started with its whole ledger in one
-- series, and each source type added later (webshop_order, vat_settlement,
-- expense_payout, ...) silently joined it through the resolver's 'A' fallback.
--
-- New rows now default to the standard set. The letters are Fortnox's, the
-- same ones VOUCHER_SERIES_PRESETS already names in every picker, so a ledger
-- imported from Fortnox continues its kundfakturor in B, leverantörsfakturor
-- in D and löner in K instead of starting parallel series:
-- A manual entries, bank transactions, and everything not listed below
-- B kundfakturor, kreditfakturor, påminnelseavgifter
-- C inbetalningar från kunder (incl. rot/rut payouts clearing 1513)
-- D leverantörsfakturor, leverantörskreditfakturor, privat betalda
-- E utbetalningar till leverantörer
-- H periodiseringar
-- I bokslut, resultatdisposition
-- K lön
-- L kontantfakturor (webshop orders)
-- M momsredovisning
-- The JSON below must stay equal to STANDARD_VOUCHER_SERIES_MAP in
-- lib/bookkeeping/voucher-series-resolver.ts and name every value the
-- journal_entries.source_type CHECK accepts;
-- tests/pg/voucher-series-standard-default.pg.test.ts asserts both.
--
-- Existing rows are deliberately NOT updated. A company that has been booking
-- on A keeps A for every type until it chooses the standard set itself under
-- Inställningar > Bokföring ("Använd standarduppsättningen"). Each series must
-- be unbroken within the räkenskapsår (BFL 5 kap. 7 §); a switch does not
-- break that, but a remap by migration would move the next kundfaktura from
-- A(n+1) into a fresh B1 mid-year with nothing in the behandlingshistorik
-- saying who decided it. BFNAR 2013:2 p. 9.16 records changes to the
-- behandlingsregler with date and actor, which the audited settings save
-- provides and a migration cannot.
ALTER TABLE public.company_settings
ALTER COLUMN default_voucher_series_per_source_type
SET DEFAULT '{
"manual": "A",
"bank_transaction": "A",
"invoice_created": "B",
"credit_note": "B",
"reminder_fee": "B",
"invoice_paid": "C",
"invoice_cash_payment": "C",
"rot_rut_payout": "C",
"supplier_invoice_registered": "D",
"supplier_credit_note": "D",
"supplier_invoice_privately_paid": "D",
"supplier_invoice_paid": "E",
"supplier_invoice_cash_payment": "E",
"accrual": "H",
"year_end": "I",
"result_appropriation": "I",
"salary_payment": "K",
"webshop_order": "L",
"vat_settlement": "M",
"opening_balance": "A",
"currency_revaluation": "A",
"inbox_item": "A",
"import": "A",
"system": "A",
"storno": "A",
"correction": "A",
"stripe_payout": "A",
"expense_claim": "A",
"expense_payout": "A"
}'::jsonb;
COMMENT ON COLUMN public.company_settings.default_voucher_series_per_source_type IS
'Maps journal_entries.source_type -> default voucher_series (single uppercase letter A-Z). Read by lib/bookkeeping/voucher-series-resolver.ts (missing key -> "A"); written via /api/settings. New rows default to STANDARD_VOUCHER_SERIES_MAP (A manual/bank, B kundfakturor, C inbetalningar, D leverantörsfakturor, E utbetalningar, H periodisering, I bokslut, K lön, L kontantfaktura, M moms); rows created before 20260906210500 keep every type on "A" until the company applies the standard set in settings.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,92 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, it, expect } from 'vitest'
import { getPool } from './setup'
import { seedCompany } from './fixtures'
import { STANDARD_VOUCHER_SERIES_MAP } from '@/lib/bookkeeping/voucher-series-resolver'
/**
* `company_settings.default_voucher_series_per_source_type` column default
* (migration 20260906210500, issue #2184).
*
* Three invariants, all against the real column:
* 1. a fresh row gets the standard set, equal to the TS constant the
* settings action writes, so a new company and "Använd
* standarduppsättningen" mean the same thing;
* 2. the default names every source type journal_entries accepts, so a new
* source type cannot slip back onto 'A' through the resolver's fallback
* the way seven of them had before this migration;
* 3. re-running the migration leaves an existing all-A row alone: the
* standard set reaches an existing company only when it asks for it.
* Superuser pool on purpose: the object under test is the default, not RLS.
*/
const MIGRATION_PATH = join(
process.cwd(),
'supabase/migrations/20260906210500_voucher_series_standard_default.sql',
)
type SeriesMap = Record<string, string>
const LEGACY_ALL_A: SeriesMap = Object.fromEntries(
Object.keys(STANDARD_VOUCHER_SERIES_MAP).map((sourceType) => [sourceType, 'A']),
)
async function insertSettings(): 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 readMap(companyId: string): Promise<SeriesMap> {
const { rows } = await getPool().query<{ map: SeriesMap }>(
`SELECT default_voucher_series_per_source_type AS map
FROM public.company_settings
WHERE company_id = $1`,
[companyId],
)
return rows[0].map
}
describe('company_settings.default_voucher_series_per_source_type default', () => {
it('gives a fresh row the standard set, equal to STANDARD_VOUCHER_SERIES_MAP', async () => {
const companyId = await insertSettings()
expect(await readMap(companyId)).toEqual(STANDARD_VOUCHER_SERIES_MAP)
})
it('names every source type the journal_entries CHECK accepts', async () => {
const { rows } = await getPool().query<{ def: string }>(
`SELECT pg_get_constraintdef(oid) AS def
FROM pg_constraint
WHERE conrelid = 'public.journal_entries'::regclass
AND conname = 'journal_entries_source_type_check'`,
)
expect(rows).toHaveLength(1)
const accepted = Array.from(rows[0].def.matchAll(/'([a-z_]+)'/g), (m) => m[1]).sort()
expect(accepted.length).toBeGreaterThan(0)
const companyId = await insertSettings()
expect(Object.keys(await readMap(companyId)).sort()).toEqual(accepted)
})
it('re-applying the migration leaves an existing all-A row untouched', async () => {
const existing = await insertSettings()
await getPool().query(
`UPDATE public.company_settings
SET default_voucher_series_per_source_type = $2::jsonb
WHERE company_id = $1`,
[existing, JSON.stringify(LEGACY_ALL_A)],
)
await getPool().query(readFileSync(MIGRATION_PATH, 'utf8'))
expect(await readMap(existing)).toEqual(LEGACY_ALL_A)
// ...while a row created afterwards still starts on the standard set.
const fresh = await insertSettings()
expect(await readMap(fresh)).toEqual(STANDARD_VOUCHER_SERIES_MAP)
})
})