feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings (#2160)
* feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings A company running several bank accounts (main bank on A, company card on M, both imported via CSV) could not route each account's bookings into its own series: every bank_transaction booking took the single company-wide default from default_voucher_series_per_source_type. - cash_accounts.voucher_series (nullable, single letter): per-account override, editable under Inställningar → Bokföring → Verifikationsserier per bankkonto (new PATCH /api/cash-accounts/[id]). - resolveCashAccountVoucherSeries(): step 2 of the resolution order (explicit pick → account override → per-type map → A). Wired into the book route and createTransactionJournalEntry, which covers categorize, the agent, pending operations and the v1 API. - Booking dialog gets the series picker, seeded from the server via /voucher-sequences/next?source_type&cash_account_id so dialog and route can never disagree. An unresolved embedded picker omits voucher_series so a stray 'A' never overrides the account's series. Scope: bank_transaction bookings only. Invoice settlements matched from the bank keep their payment series; bulk-book resolves inside its RPC (see DECISIONS.md). Migration applied to staging as 20260902121420. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh * fix(bookkeeping): audit and document the per-bankkonto series, tighten preview and PATCH Consolidated pass over the PR #2160 findings (skeptics, CodeRabbit, Swedish compliance review): - Behandlingshistorik (BFNAR 2013:2 p. 9.16): changing cash_accounts.voucher_series is a behandlingsregel that outranks the audited per-type map. New trigger audit_cash_accounts_voucher_series (UPDATE only, WHEN the series changes, so bank-sync churn never logs), cash_accounts added to AUDITED_TABLES and the audit_log filter, "Bankkonto ... Verifikationsserie: (tomt) -> M" events in the report, pg-real test. Applied to staging as 20260902124513. - Systemdokumentation (p. 9.2-9.15): revision/systemdokumentation.json gains a verifikationsserier_regler block with the resolution order and the two exceptions (invoice settlements, samlingsverifikat); the per-account mapping itself is in data/cash_accounts.json. - Settings picker uses the same closed list as the manual verifikat form (presets plus letters already in use) instead of all 26 letters; strings moved to messages/sv.json and messages/en.json. - /voucher-sequences/next applies the account override only for source_type=bank_transaction (CodeRabbit), so a manual-entry preview cannot show a series the entry will not get. - Book route resolves the series from the account the row ends up on after a stranded-row repoint, not the stale one. - PATCH /api/cash-accounts/[id] answers 404 for a non-UUID id instead of a Postgres cast 500; the series lookup logs a warning when it fails open. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1483,6 +1483,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-01] ENABLE_BANKING_SANDBOX removed from the enable-banking manifest and the index.ts header (#2131): the variable was declared as optional but never read anywhere; sandbox vs production is decided by ENABLE_BANKING_API_URL (api.tilisy.com vs api.enablebanking.com, api-client.ts derives isSandbox from the host). A dead variable declared in the manifest is what the self-hosting docs would otherwise have copied. The manifest now lists the two optional variables the code actually reads (API_URL, PSU_TYPE); the _PRODUCTION aliases stay undeclared on purpose, they are a hosted Vercel convention, not an operator contract.
|
||||
[2026-09-02] Repo-wide bloat sweep (chore/bloat-sweep-2026-09): removed dead files/exports/types/i18n namespaces and deduplicated byte-identical helpers into canonical homes (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text pdfAmount/formatDateSv, lib/browser/panel-request, lib/api/v1/body + v1ValidationError, lib/bookkeeping/booking-template-schemas). Deliberately NOT done: naive Math.round(x*100)/100 helpers were not swapped for roundOre (behaviour change at half-ore values, ratchet campaign owns it); lib/bokslut/rounding.ts shim kept because money.test.ts asserts the back-compat re-export; text-based v1 body parsers (empty body allowed) kept inline because readV1JsonBody has different empty-body semantics; the four HTTP endpoints with no first-party caller (skatteverket agi/underlag + agi/sparad DELETE, invoice-inbox items/:id/history, mail connections/backfill) stay because removing a reachable endpoint is a surface change; VacationBalanceCard deleted as unreachable since #1130, the v1/MCP vacation-year-close routes stay.
|
||||
[2026-09-01] PR #2130 security-scan round: the register's djuplank is validated (https + skatteverket.se host) before it is returned or navigated to, since the settings page follows it; a contested org number now WITHDRAWS an already-recorded grant nightly (not only blocks new ones), outside the downgrade guards on purpose. NOT done: proof of org-number ownership (Bolagsverket firmatecknare / BankID) before any ombud grant; the org number is tenant-editable across the product (AGI, invoices, årsredovisning) and binding it to a verified identity is a product decision for Emil, tracked as a follow-up rather than declined.
|
||||
[2026-09-02] Verifikationsserie per bankkonto applies to bank_transaction bookings only (book route, categorize, agent, pending operations), not to invoice settlements matched from the bank or to bulk-book samlingsverifikat: invoice_paid/supplier_invoice_paid series describe the payment kind and match-* routes have eight builder branches that do not take a series; bulk-book resolves its series inside the bulk_book_transactions RPC. One predictable rule beats partial coverage; extend later if a user asks.
|
||||
[2026-09-02] Grok custom connectors are allowlisted by the exact callback https://grok.com/connectors-oauth-exchange-code/ (trailing slash optional), not a grok.com prefix: the value is published by X Corp at docs.x.com/x-ads-api/mcp ("Grok (web)" redirect URL) and grok.com serves the path itself (slash form 308s to no-slash on the same origin), and a prefix would let any future grok.com path receive authorization codes. Grok gets side doors next to ChatGPT (onboarding) and a row under "Other clients" (settings); Claude stays the visual primary per the 2026-08-27 founder call. No new client marker plumbing: the settings URL uses the existing ?client= param with value grok.
|
||||
[2026-09-02] Viewer write gate as ONE table-level trigger (enforce_company_writer_role) instead of re-emitting 15 SECURITY DEFINER bodies and ~130 policies: keyed on the JWT role claim so it fires inside definer functions too; no-op for service_role and trigger cascades. agent_conversations/agent_messages and telemetry tables deliberately excluded.
|
||||
[2026-09-02] Posting-integrity guards key on current_user IN ('anon','authenticated'), not the JWT claim: inside SECURITY DEFINER RPCs current_user is the definer, so commit_journal_entry, SIE import, storno and rättelse keep working while direct PostgREST manipulation of posted vouchers is blocked. Residual: a direct draft->posted flip may still reuse an unused number below the sequence high-water mark.
|
||||
|
||||
@@ -173,6 +173,113 @@ describe('GET /api/bookkeeping/voucher-sequences/next', () => {
|
||||
expect(body.data).toEqual({ next: 5, series: 'V', fiscal_period_id: 'period-1' })
|
||||
})
|
||||
|
||||
it("prefers the cash account's own series over the per-source-type map", async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
mockFrom.mockImplementation((table: string) => {
|
||||
if (table === 'fiscal_periods') {
|
||||
return mockChain({ data: { id: 'period-1' }, error: null })
|
||||
}
|
||||
if (table === 'company_settings') {
|
||||
return mockChain({
|
||||
data: {
|
||||
default_voucher_series: 'A',
|
||||
default_voucher_series_per_source_type: { bank_transaction: 'B' },
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
if (table === 'cash_accounts') {
|
||||
return mockChain({ data: { voucher_series: 'M' }, error: null })
|
||||
}
|
||||
if (table === 'voucher_sequences') {
|
||||
return mockChain({ data: { last_number: 9 }, error: null })
|
||||
}
|
||||
throw new Error(`Unexpected table: ${table}`)
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
mkReq('?source_type=bank_transaction&cash_account_id=11111111-1111-4111-8111-111111111111'),
|
||||
mkParams(),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toEqual({ next: 10, series: 'M', fiscal_period_id: 'period-1' })
|
||||
})
|
||||
|
||||
it('falls through to the per-source-type map when the cash account has no override', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
mockFrom.mockImplementation((table: string) => {
|
||||
if (table === 'fiscal_periods') {
|
||||
return mockChain({ data: { id: 'period-1' }, error: null })
|
||||
}
|
||||
if (table === 'company_settings') {
|
||||
return mockChain({
|
||||
data: {
|
||||
default_voucher_series: 'A',
|
||||
default_voucher_series_per_source_type: { bank_transaction: 'B' },
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
if (table === 'cash_accounts') {
|
||||
return mockChain({ data: { voucher_series: null }, error: null })
|
||||
}
|
||||
if (table === 'voucher_sequences') {
|
||||
return mockChain({ data: null, error: null })
|
||||
}
|
||||
throw new Error(`Unexpected table: ${table}`)
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
mkReq('?source_type=bank_transaction&cash_account_id=11111111-1111-4111-8111-111111111111'),
|
||||
mkParams(),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toEqual({ next: 1, series: 'B', fiscal_period_id: 'period-1' })
|
||||
})
|
||||
|
||||
it('ignores the cash account override for source types other than bank_transaction', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
mockFrom.mockImplementation((table: string) => {
|
||||
if (table === 'fiscal_periods') {
|
||||
return mockChain({ data: { id: 'period-1' }, error: null })
|
||||
}
|
||||
if (table === 'company_settings') {
|
||||
return mockChain({
|
||||
data: { default_voucher_series: 'A', default_voucher_series_per_source_type: { manual: 'V' } },
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
if (table === 'voucher_sequences') {
|
||||
return mockChain({ data: { last_number: 2 }, error: null })
|
||||
}
|
||||
throw new Error(`Unexpected table: ${table}`)
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
mkReq('?source_type=manual&cash_account_id=11111111-1111-4111-8111-111111111111'),
|
||||
mkParams(),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toEqual({ next: 3, series: 'V', fiscal_period_id: 'period-1' })
|
||||
// cash_accounts was never consulted (the mock would have thrown).
|
||||
})
|
||||
|
||||
it('rejects a malformed cash_account_id with 400 before touching the database', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
const response = await GET(mkReq('?source_type=bank_transaction&cash_account_id=nope'), mkParams())
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockFrom).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to A when the source_type has no per-source-type mapping', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
import { VoucherSequenceNextQuerySchema } from '@/lib/api/schemas'
|
||||
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { resolveCashAccountVoucherSeries } from '@/lib/bookkeeping/cash-account-voucher-series'
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'voucher_sequence.next',
|
||||
@@ -15,7 +16,12 @@ export const GET = withRouteContext(
|
||||
operation: 'voucher_sequence.next',
|
||||
})
|
||||
if (!query.success) return query.response
|
||||
const { period_id: overridePeriodId, series: overrideSeries, source_type: sourceType } = query.data
|
||||
const {
|
||||
period_id: overridePeriodId,
|
||||
series: overrideSeries,
|
||||
source_type: sourceType,
|
||||
cash_account_id: cashAccountId,
|
||||
} = query.data
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
// Vouchers are numbered per fiscal period, so the preview must reflect the
|
||||
@@ -57,14 +63,23 @@ export const GET = withRouteContext(
|
||||
}
|
||||
|
||||
// When a source_type is supplied, resolve the series exactly as the booking
|
||||
// engine does (per-source-type map → 'A'), so the preview can never disagree
|
||||
// with the verifikat that actually gets created. Without a source_type, keep
|
||||
// the legacy generic default for callers that just want "the next number".
|
||||
// engine does (cash account override → per-source-type map → 'A'), so the
|
||||
// preview can never disagree with the verifikat that actually gets created.
|
||||
// Without a source_type, keep the legacy generic default for callers that
|
||||
// just want "the next number".
|
||||
// The account override only applies to entries booked from bank
|
||||
// transactions; for any other source type it must not colour the preview.
|
||||
const cashAccountSeries =
|
||||
!overrideSeries && cashAccountId && sourceType === 'bank_transaction'
|
||||
? await resolveCashAccountVoucherSeries(supabase, companyId, cashAccountId)
|
||||
: undefined
|
||||
const series = overrideSeries
|
||||
? overrideSeries
|
||||
: sourceType
|
||||
? resolveDefaultSeriesForSource(settings, sourceType)
|
||||
: settings?.default_voucher_series || 'A'
|
||||
: cashAccountSeries
|
||||
? cashAccountSeries
|
||||
: sourceType
|
||||
? resolveDefaultSeriesForSource(settings, sourceType)
|
||||
: settings?.default_voucher_series || 'A'
|
||||
|
||||
if (!period) {
|
||||
return NextResponse.json({ data: { next: null, series, fiscal_period_id: null } })
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
import { PATCH } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
|
||||
const CA_1 = '11111111-1111-4111-8111-111111111111'
|
||||
const CA_OTHER = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
describe('PATCH /api/cash-accounts/[id] (verifikationsserie per bankkonto)', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
function patchReq(body: unknown) {
|
||||
return new Request('http://localhost/api/cash-accounts/ca-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: mockUser as never,
|
||||
supabase: mockSupabase as never,
|
||||
error: null,
|
||||
})
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: null as never,
|
||||
supabase: mockSupabase as never,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_1 }))
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when the caller is a viewer', async () => {
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
|
||||
const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_1 }))
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 400 on a malformed series (must be one uppercase letter)', async () => {
|
||||
for (const bad of ['m', 'AB', '', 7]) {
|
||||
const response = await PATCH(patchReq({ voucher_series: bad }), createMockRouteParams({ id: CA_1 }))
|
||||
expect(response.status).toBe(400)
|
||||
}
|
||||
expect(findCalls('cash_accounts', 'update')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 400 when voucher_series is missing entirely', async () => {
|
||||
const response = await PATCH(patchReq({}), createMockRouteParams({ id: CA_1 }))
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 for an id that is not a UUID, without touching the database', async () => {
|
||||
const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: 'not-a-uuid' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('CASH_ACCOUNT_NOT_FOUND')
|
||||
expect(findCalls('cash_accounts', 'update')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 404 when the account does not belong to the company', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_OTHER }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('CASH_ACCOUNT_NOT_FOUND')
|
||||
const eqCalls = findCalls('cash_accounts', 'eq')
|
||||
expect(eqCalls).toContainEqual(['company_id', 'company-1'])
|
||||
expect(eqCalls).toContainEqual(['id', CA_OTHER])
|
||||
})
|
||||
|
||||
it('sets the series and returns the updated account (happy path)', async () => {
|
||||
enqueue({ data: { id: 'ca-1', ledger_account: '1931', voucher_series: 'M' }, error: null })
|
||||
|
||||
const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_1 }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { voucher_series: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.voucher_series).toBe('M')
|
||||
expect(findCalls('cash_accounts', 'update')).toContainEqual([{ voucher_series: 'M' }])
|
||||
})
|
||||
|
||||
it('clears the override with null so the account follows the per-type default again', async () => {
|
||||
enqueue({ data: { id: 'ca-1', ledger_account: '1931', voucher_series: null }, error: null })
|
||||
|
||||
const response = await PATCH(patchReq({ voucher_series: null }), createMockRouteParams({ id: CA_1 }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { voucher_series: string | null } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.voucher_series).toBeNull()
|
||||
expect(findCalls('cash_accounts', 'update')).toContainEqual([{ voucher_series: null }])
|
||||
})
|
||||
|
||||
it('maps a database error to the canonical error envelope', async () => {
|
||||
enqueue({ data: null, error: { message: 'boom', code: '42P01' } })
|
||||
|
||||
const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_1 }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBeGreaterThanOrEqual(400)
|
||||
expect(body.error).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateCashAccountVoucherSeriesSchema } from '@/lib/api/schemas'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { setVoucherSeries } from '@/lib/cash-accounts/service'
|
||||
import { UUID_RE } from '@/lib/invariants/uuid'
|
||||
|
||||
/** Canonical 404 for an id that is not one of the company's bank accounts. */
|
||||
function notFound(): NextResponse {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'CASH_ACCOUNT_NOT_FOUND',
|
||||
message: 'Bankkontot hittades inte.',
|
||||
message_en: 'Bank account not found.',
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/cash-accounts/[id]
|
||||
*
|
||||
* Sets or clears the verifikationsserie override on one of the company's
|
||||
* bank accounts. Only this one field is editable here: ledger account and
|
||||
* primary flag have their own guarded flows (unique constraint, atomic RPC).
|
||||
*/
|
||||
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'cash_accounts.update',
|
||||
async (request, { supabase, companyId, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
// A non-UUID id can never match a row; answer 404 instead of letting the
|
||||
// uuid cast surface as a 500 from Postgres.
|
||||
if (!UUID_RE.test(id)) return notFound()
|
||||
const validation = await validateBody(request, UpdateCashAccountVoucherSeriesSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
let updated
|
||||
try {
|
||||
updated = await setVoucherSeries(supabase, companyId, id, validation.data.voucher_series)
|
||||
} catch (err) {
|
||||
log.error('cash_accounts voucher_series update failed', err as Error)
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
|
||||
if (!updated) return notFound()
|
||||
|
||||
return NextResponse.json({ data: updated })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -237,6 +237,84 @@ describe('POST /api/transactions/[id]/book', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("books into the bank account's own verifikationsserie when the cash account carries one", async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
journal_entry_id: null,
|
||||
cash_account_id: 'ca-card',
|
||||
})
|
||||
const je = makeJournalEntry({ id: 'je-new', voucher_series: 'M' })
|
||||
|
||||
// Fetch transaction
|
||||
enqueue({ data: tx, error: null })
|
||||
// guardBookedCounterLines own-row lookup (1930 matches the own ledger: clean)
|
||||
enqueue({ data: { ledger_account: '1930' }, error: null })
|
||||
// Cash account series override
|
||||
enqueue({ data: { voucher_series: 'M' }, error: null })
|
||||
mockCreateJournalEntry.mockResolvedValue(je)
|
||||
// Update transaction
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ source_type: 'bank_transaction', voucher_series: 'M' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('lets an explicit voucher_series from the dialog win over the cash account override', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
journal_entry_id: null,
|
||||
cash_account_id: 'ca-card',
|
||||
})
|
||||
const je = makeJournalEntry({ id: 'je-new', voucher_series: 'V' })
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { ledger_account: '1930' }, error: null })
|
||||
mockCreateJournalEntry.mockResolvedValue(je)
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: { ...validBody, voucher_series: 'V' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ voucher_series: 'V' }),
|
||||
)
|
||||
// No cash_accounts series lookup: the explicit pick short-circuits it.
|
||||
const seriesLookups = findCalls('cash_accounts', 'select').filter((args) => args[0] === 'voucher_series')
|
||||
expect(seriesLookups).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a malformed voucher_series with 400', async () => {
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: { ...validBody, voucher_series: 'ab' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT when a line books the settlement row against its active twin (#1643)', async () => {
|
||||
// The issue's dialog shape: 1930 and 1931 both enabled on one active
|
||||
// connection; "Ändra rader" pre-filled 1930 debit / 1931 credit from a
|
||||
@@ -288,6 +366,7 @@ describe('POST /api/transactions/[id]/book', () => {
|
||||
],
|
||||
}) // cash_accounts topology
|
||||
enqueue({ data: [{ id: 'conn-live', status: 'active' }] }) // bank_connections statuses
|
||||
enqueue({ data: { voucher_series: 'M' } }) // series override of the LIVE twin the row moves to
|
||||
mockCreateJournalEntry.mockResolvedValue(makeJournalEntry({ id: 'je-new' }))
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null }) // link update
|
||||
|
||||
@@ -308,6 +387,14 @@ describe('POST /api/transactions/[id]/book', () => {
|
||||
expect(findCalls('transactions', 'update')).toContainEqual([
|
||||
expect.objectContaining({ journal_entry_id: 'je-new', cash_account_id: 'ca-live' }),
|
||||
])
|
||||
// The series follows the account the row ends up on, not the stale one.
|
||||
expect(findCalls('cash_accounts', 'eq')).toContainEqual(['id', 'ca-live'])
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ voucher_series: 'M' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 400 TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT when the single bank line sits on a dead twin of the live own row (#1643 round 5)', async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { resolveCashAccountVoucherSeries } from '@/lib/bookkeeping/cash-account-voucher-series'
|
||||
import { guardBookedCounterLines } from '@/lib/cash-accounts/service'
|
||||
import { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
@@ -167,6 +168,19 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
})
|
||||
}
|
||||
|
||||
// Series: the dialog's explicit pick wins; otherwise the bank account the
|
||||
// row will sit on after this booking (the live sibling when the guard
|
||||
// re-points a stranded row, else its own) may carry its own
|
||||
// verifikationsserie; otherwise the engine falls back to the
|
||||
// per-source-type default.
|
||||
const voucherSeries =
|
||||
validation.data.voucher_series ??
|
||||
(await resolveCashAccountVoucherSeries(
|
||||
supabase,
|
||||
companyId,
|
||||
repointCashAccountId ?? (transaction as Transaction).cash_account_id,
|
||||
))
|
||||
|
||||
// Create journal entry via the engine
|
||||
let journalEntry
|
||||
try {
|
||||
@@ -177,6 +191,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
source_type: 'bank_transaction',
|
||||
source_id: id,
|
||||
lines,
|
||||
...(voucherSeries ? { voucher_series: voucherSeries } : {}),
|
||||
})
|
||||
} catch (err) {
|
||||
const typed = bookkeepingErrorResponse(err)
|
||||
|
||||
@@ -113,6 +113,13 @@ interface Props {
|
||||
*/
|
||||
extraBody?: Record<string, unknown>
|
||||
duplicateMatchTransaction?: DuplicateMatchTransaction
|
||||
/** Embedded variant only: show the series picker anyway. The series is
|
||||
* seeded from the server (source type + cash account override) so the
|
||||
* dialog and the booking route can never disagree. */
|
||||
seriesPicker?: boolean
|
||||
/** Bank account the entry is booked from; its voucher_series override
|
||||
* (Inställningar → Bokföring) seeds the picker. */
|
||||
cashAccountId?: string | null
|
||||
/** Fired after the duplicate guard's match action links the transaction to
|
||||
* the existing voucher (no new entry was created). */
|
||||
onDuplicateMatched?: (journalEntryId: string) => void
|
||||
@@ -140,6 +147,8 @@ export default function JournalEntryForm({
|
||||
onUpdated,
|
||||
extraBody,
|
||||
duplicateMatchTransaction,
|
||||
seriesPicker,
|
||||
cashAccountId,
|
||||
onDuplicateMatched,
|
||||
}: Props) {
|
||||
const { canWrite } = useCanWrite()
|
||||
@@ -181,6 +190,14 @@ export default function JournalEntryForm({
|
||||
initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }]
|
||||
)
|
||||
const [voucherSeries, setVoucherSeries] = useState(initialVoucherSeries ?? 'A')
|
||||
// Embedded forms show the picker only on request (bank transaction dialog).
|
||||
const showSeries = !embedded || !!seriesPicker
|
||||
// Whether voucherSeries is authoritative. The standalone form seeds it from
|
||||
// company settings; the embedded picker asks the server once (source type +
|
||||
// cash account override) and marks it resolved, or when the user picks. Until
|
||||
// then the submit omits voucher_series so the route resolves it itself: an
|
||||
// unresolved 'A' must never override the bank account's own series.
|
||||
const [seriesResolved, setSeriesResolved] = useState(!embedded)
|
||||
// The source_type the entry will be committed with. Seeded from the prop
|
||||
// (undefined -> 'manual' for the standalone form). Applying a booking template
|
||||
// whose category maps to a dedicated source type (e.g. VAT -> vat_settlement)
|
||||
@@ -359,7 +376,7 @@ export default function JournalEntryForm({
|
||||
// Read-only hint; the actual number is reserved atomically at commit time,
|
||||
// so this may shift by one if another entry lands first.
|
||||
useEffect(() => {
|
||||
if (embedded || !entryDate || !voucherSeries) {
|
||||
if (!showSeries || !entryDate || !voucherSeries) {
|
||||
setNextVoucherNumber(null)
|
||||
return
|
||||
}
|
||||
@@ -367,13 +384,29 @@ export default function JournalEntryForm({
|
||||
// Keyed on the entry date rather than the resolved period so the preview
|
||||
// fires as soon as the series is known: the route resolves the period
|
||||
// from the date itself, which is exactly how selectedPeriod is derived.
|
||||
const qs = new URLSearchParams({ date: entryDate, series: voucherSeries })
|
||||
// Before the embedded picker is resolved, ask by source type + cash
|
||||
// account instead of by series: the route answers with the series the
|
||||
// booking would actually get, and that seeds the picker.
|
||||
const qs = new URLSearchParams({ date: entryDate })
|
||||
if (seriesResolved) {
|
||||
qs.set('series', voucherSeries)
|
||||
} else {
|
||||
if (sourceType) qs.set('source_type', sourceType)
|
||||
if (cashAccountId) qs.set('cash_account_id', cashAccountId)
|
||||
}
|
||||
fetch(`/api/bookkeeping/voucher-sequences/next?${qs}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((body) => {
|
||||
if (cancelled) return
|
||||
const next = body?.data?.next
|
||||
setNextVoucherNumber(typeof next === 'number' ? next : null)
|
||||
if (!seriesResolved && body) {
|
||||
const resolved = body?.data?.series
|
||||
if (typeof resolved === 'string' && /^[A-Z]$/.test(resolved)) {
|
||||
setVoucherSeries(resolved)
|
||||
}
|
||||
setSeriesResolved(true)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setNextVoucherNumber(null)
|
||||
@@ -381,7 +414,7 @@ export default function JournalEntryForm({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [embedded, entryDate, voucherSeries])
|
||||
}, [showSeries, seriesResolved, entryDate, voucherSeries, sourceType, cashAccountId])
|
||||
|
||||
// Fetch exchange rate from Riksbanken when currency changes
|
||||
const fetchRate = useCallback(async (currency: Currency) => {
|
||||
@@ -1042,7 +1075,9 @@ export default function JournalEntryForm({
|
||||
description,
|
||||
source_type: effectiveSourceType,
|
||||
source_id: sourceId,
|
||||
voucher_series: voucherSeries || 'A',
|
||||
// Omitted while an embedded picker is still unresolved: see
|
||||
// seriesResolved. Endpoints that do not declare the key strip it.
|
||||
...(seriesResolved ? { voucher_series: voucherSeries || 'A' } : {}),
|
||||
notes: notes || undefined,
|
||||
lines: entryLines,
|
||||
// Set only when retrying past the booking-time duplicate guard (see
|
||||
@@ -1056,7 +1091,7 @@ export default function JournalEntryForm({
|
||||
}),
|
||||
})
|
||||
return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string }
|
||||
}, [lines, rate, entryCurrency, computedForeignAmount, t, submitUrl, editEntryId, selectedPeriod, entryDate, description, effectiveSourceType, sourceId, voucherSeries, notes, extraBody])
|
||||
}, [lines, rate, entryCurrency, computedForeignAmount, t, submitUrl, editEntryId, selectedPeriod, entryDate, description, effectiveSourceType, sourceId, voucherSeries, seriesResolved, notes, extraBody])
|
||||
|
||||
const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } =
|
||||
useSubmitWithAccountActivation(postJournalEntry)
|
||||
@@ -1410,13 +1445,19 @@ export default function JournalEntryForm({
|
||||
className="mt-1 h-8"
|
||||
/>
|
||||
</div>
|
||||
{!embedded && (
|
||||
{showSeries && (
|
||||
// Closed list, not free text: the letters carry fixed meanings
|
||||
// (A = redovisning, B = kundfakturor, ...) and a typo here silently
|
||||
// starts a new series with its own number sequence.
|
||||
<div className="w-full sm:w-72">
|
||||
<Label className="text-xs text-muted-foreground">{t('series')}</Label>
|
||||
<Select value={voucherSeries} onValueChange={(v) => setVoucherSeries(v)}>
|
||||
<Select
|
||||
value={voucherSeries}
|
||||
onValueChange={(v) => {
|
||||
setVoucherSeries(v)
|
||||
setSeriesResolved(true)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
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 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'>
|
||||
}
|
||||
|
||||
/** "Företagskort (1931)" or the bare ledger account when the row has no name. */
|
||||
function accountLabel(account: CashAccount): string {
|
||||
const name = account.name?.trim()
|
||||
return name ? `${name} (${account.ledger_account})` : account.ledger_account
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifikationsserie per bankkonto. A company that runs several bank accounts
|
||||
* (main bank on A, a company-card account on M) can route each account's
|
||||
* bookings into its own series. Blank = follow "Verifikationsserier per typ".
|
||||
* Saves per row on change, no separate save button: each row is one field on
|
||||
* one account, and the bank-transaction booking dialog reads it live.
|
||||
*
|
||||
* The picker is the same closed list as the manual verifikat form: the fixed
|
||||
* Swedish presets plus every letter the company already uses. A free A-Z list
|
||||
* would let a typo start an undocumented series (BFNAR 2013:2 p. 9.2-9.15
|
||||
* wants the series in use enumerated in the systemdokumentation).
|
||||
*/
|
||||
export function VoucherSeriesPerCashAccountForm({ settings }: Props) {
|
||||
const t = useTranslations('settings_voucher_series')
|
||||
const { toast } = useToast()
|
||||
const { cashAccounts, isLoading, refresh } = useCashAccounts({ enabledOnly: true })
|
||||
const [savingId, setSavingId] = useState<string | null>(null)
|
||||
|
||||
// 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 = [
|
||||
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])
|
||||
|
||||
/** PATCH one account's override, then refresh the shared cash-account cache. */
|
||||
const handleChange = async (account: CashAccount, value: string) => {
|
||||
const next = value === FOLLOW_DEFAULT ? null : value
|
||||
if ((account.voucher_series ?? null) === next) return
|
||||
setSavingId(account.id)
|
||||
try {
|
||||
const res = await fetch(`/api/cash-accounts/${account.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voucher_series: next }),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: t('per_account_save_failed'),
|
||||
description: getErrorMessage(json, { context: 'settings', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
toast({
|
||||
title: t('per_account_saved_title'),
|
||||
description: next
|
||||
? t('per_account_saved_set', { account: accountLabel(account), series: next })
|
||||
: t('per_account_saved_cleared', { account: accountLabel(account) }),
|
||||
})
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('per_account_save_failed'),
|
||||
description: getErrorMessage(err, { context: 'settings' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSavingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsGroup label={t('per_account_heading')} help={t('per_account_help')}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 px-1 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
{t('per_account_loading')}
|
||||
</div>
|
||||
) : cashAccounts.length === 0 ? (
|
||||
<p className="px-1 py-3 text-sm text-muted-foreground">{t('per_account_empty')}</p>
|
||||
) : (
|
||||
cashAccounts.map((account, i) => (
|
||||
<SettingsRow
|
||||
key={account.id}
|
||||
label={accountLabel(account)}
|
||||
htmlFor={`series-cash-account-${account.id}`}
|
||||
borderless={i === cashAccounts.length - 1}
|
||||
>
|
||||
<SettingsSelect
|
||||
id={`series-cash-account-${account.id}`}
|
||||
value={account.voucher_series ?? FOLLOW_DEFAULT}
|
||||
onChange={(e) => void handleChange(account, e.target.value)}
|
||||
disabled={savingId === account.id}
|
||||
className="font-mono"
|
||||
>
|
||||
<option value={FOLLOW_DEFAULT}>{t('per_account_follow_default')}</option>
|
||||
{seriesOptions.map((option) => (
|
||||
<option key={option.letter} value={option.letter}>
|
||||
{option.label ? `${option.letter} ${option.label}` : option.letter}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
</SettingsRow>
|
||||
))
|
||||
)}
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettin
|
||||
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 { DimensionsToggle } from '@/components/settings/DimensionsToggle'
|
||||
import { MileageToggle } from '@/components/settings/MileageToggle'
|
||||
@@ -169,6 +170,8 @@ export function BookkeepingSettingsContent() {
|
||||
onSettingsUpdated={updateSettings}
|
||||
/>
|
||||
|
||||
<VoucherSeriesPerCashAccountForm settings={settings} />
|
||||
|
||||
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
|
||||
|
||||
<SettingsGroup label={t('group_automation')}>
|
||||
|
||||
@@ -430,6 +430,10 @@ export default function TransactionBookingDialog({
|
||||
submitUrl={`/api/transactions/${transaction.id}/book`}
|
||||
sourceType="bank_transaction"
|
||||
sourceId={transaction.id}
|
||||
// Series picker seeded from the bank account's own series
|
||||
// (Inställningar → Bokföring → Verifikationsserie per bankkonto).
|
||||
seriesPicker
|
||||
cashAccountId={transaction.cash_account_id ?? null}
|
||||
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
|
||||
duplicateMatchTransaction={{
|
||||
id: transaction.id,
|
||||
|
||||
@@ -19,6 +19,7 @@ function makeCashAccount(overrides: Partial<CashAccount> = {}): CashAccount {
|
||||
enabled: true,
|
||||
is_primary: true,
|
||||
source: 'manual',
|
||||
voucher_series: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
|
||||
@@ -291,6 +291,9 @@ export const VoucherSequenceNextQuerySchema = z.object({
|
||||
period_id: uuid.optional(),
|
||||
series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z').optional(),
|
||||
source_type: JournalEntrySourceTypeSchema.optional(),
|
||||
// Bank account the entry is booked from: its voucher_series override (when
|
||||
// set) takes precedence over the per-source-type default.
|
||||
cash_account_id: uuid.optional(),
|
||||
date: isoDate.optional(),
|
||||
})
|
||||
|
||||
@@ -1590,6 +1593,9 @@ export const BookTransactionSchema = z
|
||||
entry_date: isoDate,
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
lines: z.array(CreateJournalEntryLineSchema).min(1, 'At least one line is required'),
|
||||
// Explicit series from the booking dialog's picker. Omitted: the route
|
||||
// resolves it from the transaction's cash account, then the per-type map.
|
||||
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z').optional(),
|
||||
// Booking-time duplicate guard: see CategorizeTransactionSchema.
|
||||
force: z.boolean().optional(),
|
||||
expected_duplicate_transaction_id: uuid.optional(),
|
||||
@@ -1721,6 +1727,18 @@ export const MoveTransactionCashAccountSchema = z.object({
|
||||
.regex(/^19\d{2}$/, 'Expected a BAS 19xx bank account number'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Set or clear the verifikationsserie override on one of the company's cash
|
||||
* accounts. null clears the override: entries booked from the account then
|
||||
* follow the per-source-type default again.
|
||||
*/
|
||||
export const UpdateCashAccountVoucherSeriesSchema = z.object({
|
||||
voucher_series: z
|
||||
.string()
|
||||
.regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z')
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
export const BookInboxItemDirectlySchema = z.object({
|
||||
fiscal_period_id: uuid,
|
||||
entry_date: isoDate,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
cashAccountSeriesOverride,
|
||||
resolveCashAccountVoucherSeries,
|
||||
} from '../cash-account-voucher-series'
|
||||
|
||||
const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
|
||||
describe('cashAccountSeriesOverride', () => {
|
||||
it('returns the letter when the account carries a valid override', () => {
|
||||
expect(cashAccountSeriesOverride({ voucher_series: 'M' })).toBe('M')
|
||||
})
|
||||
|
||||
it('returns undefined for null, missing, lowercase or multi-letter values', () => {
|
||||
expect(cashAccountSeriesOverride(null)).toBeUndefined()
|
||||
expect(cashAccountSeriesOverride(undefined)).toBeUndefined()
|
||||
expect(cashAccountSeriesOverride({ voucher_series: null })).toBeUndefined()
|
||||
expect(cashAccountSeriesOverride({})).toBeUndefined()
|
||||
expect(cashAccountSeriesOverride({ voucher_series: 'm' })).toBeUndefined()
|
||||
expect(cashAccountSeriesOverride({ voucher_series: 'AB' })).toBeUndefined()
|
||||
expect(cashAccountSeriesOverride({ voucher_series: '' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCashAccountVoucherSeries', () => {
|
||||
beforeEach(() => {
|
||||
reset()
|
||||
})
|
||||
|
||||
it('skips the lookup entirely when the transaction has no cash account', async () => {
|
||||
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', null)).toBeUndefined()
|
||||
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', undefined)).toBeUndefined()
|
||||
expect(findCalls('cash_accounts', 'select')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns the account override, scoped to the company', async () => {
|
||||
enqueue({ data: { voucher_series: 'M' }, error: null })
|
||||
|
||||
const series = await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-1')
|
||||
|
||||
expect(series).toBe('M')
|
||||
const eqCalls = findCalls('cash_accounts', 'eq')
|
||||
expect(eqCalls).toContainEqual(['company_id', 'company-1'])
|
||||
expect(eqCalls).toContainEqual(['id', 'ca-1'])
|
||||
})
|
||||
|
||||
it('returns undefined when the account has no override', async () => {
|
||||
enqueue({ data: { voucher_series: null }, error: null })
|
||||
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when the account is unknown', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails open (undefined) on a query error so the booking still goes through', async () => {
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -468,6 +468,30 @@ describe('createTransactionJournalEntry', () => {
|
||||
expect(input.source_id).toBe('tx-abc-123')
|
||||
})
|
||||
|
||||
it("books into the cash account's verifikationsserie when the account carries one", async () => {
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
reset()
|
||||
enqueue({ data: { voucher_series: 'M' }, error: null })
|
||||
const tx = makeTransaction({ amount: -100, cash_account_id: 'ca-card' })
|
||||
|
||||
await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, makeMappingResult())
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.voucher_series).toBe('M')
|
||||
})
|
||||
|
||||
it('omits voucher_series (engine resolves the per-type default) when the account has no override', async () => {
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
reset()
|
||||
enqueue({ data: { voucher_series: null }, error: null })
|
||||
const tx = makeTransaction({ amount: -100, cash_account_id: 'ca-main' })
|
||||
|
||||
await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, makeMappingResult())
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect('voucher_series' in input).toBe(false)
|
||||
})
|
||||
|
||||
it('uses transaction.date as entry_date', async () => {
|
||||
const tx = makeTransaction({ date: '2024-09-15', amount: -100 })
|
||||
const mapping = makeMappingResult()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Verifikationsserie per bankkonto.
|
||||
*
|
||||
* Resolution order for an entry booked from a bank transaction:
|
||||
* 1. explicit voucher_series in the request (the booking dialog's picker)
|
||||
* 2. cash_accounts.voucher_series of the transaction's account (this module)
|
||||
* 3. company_settings.default_voucher_series_per_source_type (engine)
|
||||
* 4. 'A' (engine)
|
||||
*
|
||||
* This helper covers step 2 only. It returns undefined whenever there is no
|
||||
* override so callers can pass the result straight into CreateJournalEntryInput
|
||||
* and let the engine handle steps 3 and 4. Lookup failures also resolve to
|
||||
* undefined: a broken override must never block a booking, the entry then
|
||||
* lands in the per-type default exactly as before this feature.
|
||||
*
|
||||
* Scope: bank-transaction bookings (book route, categorize, agent, pending
|
||||
* operations). Invoice settlements matched from the bank keep the invoice
|
||||
* payment series (invoice_paid, supplier_invoice_paid): those series describe
|
||||
* the payment kind, not the account the money moved through.
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('cash-account-voucher-series')
|
||||
const SERIES_LETTER_RE = /^[A-Z]$/
|
||||
|
||||
/** Pure: the override letter of a cash account row, or undefined. */
|
||||
export function cashAccountSeriesOverride(
|
||||
account: { voucher_series?: string | null } | null | undefined,
|
||||
): string | undefined {
|
||||
const value = account?.voucher_series
|
||||
return typeof value === 'string' && SERIES_LETTER_RE.test(value) ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the series override of one of the company's cash accounts.
|
||||
* undefined when the account is unknown, has no override, or the query fails.
|
||||
*/
|
||||
export async function resolveCashAccountVoucherSeries(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
cashAccountId: string | null | undefined,
|
||||
): Promise<string | undefined> {
|
||||
if (!cashAccountId) return undefined
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('voucher_series')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', cashAccountId)
|
||||
.maybeSingle()
|
||||
if (error) {
|
||||
// Fail open, but never silently: the entry lands in the per-type
|
||||
// default and the log says why.
|
||||
log.warn('cash_accounts voucher_series lookup failed; using per-type default', {
|
||||
companyId,
|
||||
cashAccountId,
|
||||
error: error.message,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
return cashAccountSeriesOverride(data as { voucher_series?: string | null } | null)
|
||||
} catch (err) {
|
||||
log.warn('cash_accounts voucher_series lookup threw; using per-type default', {
|
||||
companyId,
|
||||
cashAccountId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createJournalEntry, findFiscalPeriod } from './engine'
|
||||
import { resolveCashAccountVoucherSeries } from './cash-account-voucher-series'
|
||||
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
|
||||
import { coerceDimensionsBag } from './dimension-resolver'
|
||||
import { extractNetAmount, extractVatAmount } from './vat-entries'
|
||||
@@ -338,6 +339,14 @@ export async function createTransactionJournalEntry(
|
||||
? [baseDescription, ...extraParts].filter(Boolean).join(' · ').slice(0, 500)
|
||||
: baseDescription
|
||||
|
||||
// The transaction's bank account may carry its own verifikationsserie;
|
||||
// undefined lets the engine fall back to the per-source-type default.
|
||||
const voucherSeries = await resolveCashAccountVoucherSeries(
|
||||
supabase,
|
||||
companyId,
|
||||
transaction.cash_account_id,
|
||||
)
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: entryDate,
|
||||
@@ -345,6 +354,7 @@ export async function createTransactionJournalEntry(
|
||||
source_type: 'bank_transaction',
|
||||
source_id: transaction.id,
|
||||
lines,
|
||||
...(voucherSeries ? { voucher_series: voucherSeries } : {}),
|
||||
}
|
||||
|
||||
return createJournalEntry(supabase, companyId, userId, input)
|
||||
|
||||
@@ -1523,6 +1523,28 @@ export async function setLedgerAccount(
|
||||
if (error) throw new Error(`cash_accounts setLedgerAccount failed: ${error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear the verifikationsserie override for a cash account. null means
|
||||
* "follow the per-source-type default"; the engine reads this via
|
||||
* resolveCashAccountVoucherSeries() when it books from the account.
|
||||
*/
|
||||
export async function setVoucherSeries(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
cashAccountId: string,
|
||||
voucherSeries: string | null,
|
||||
): Promise<CashAccount | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('cash_accounts')
|
||||
.update({ voucher_series: voucherSeries })
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', cashAccountId)
|
||||
.select('*')
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`cash_accounts setVoucherSeries failed: ${error.message}`)
|
||||
return (data as CashAccount | null) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a cash account as the primary for its company. Delegates to the
|
||||
* `set_cash_account_primary` RPC so the clear-old-primary and set-new-primary
|
||||
|
||||
@@ -707,6 +707,30 @@ describe('auditRowToEvent: behandlingsregler', () => {
|
||||
expect(upd.details).toEqual(['Debetkonto: 6540 → 6212'])
|
||||
})
|
||||
|
||||
it('cash_accounts: a verifikationsserie change names the account and diffs the series', () => {
|
||||
const upd = auditRowToEvent(
|
||||
auditRow({
|
||||
table_name: 'cash_accounts',
|
||||
action: 'UPDATE',
|
||||
old_state: { name: 'Företagskort', ledger_account: '1931', voucher_series: null, balance: 100, updated_at: 'x' },
|
||||
new_state: { name: 'Företagskort', ledger_account: '1931', voucher_series: 'M', balance: 250, updated_at: 'y' },
|
||||
}),
|
||||
)!
|
||||
expect(upd).toMatchObject({ category: 'installningar', code: 'cash_account.updated', object: 'Företagskort 1931' })
|
||||
expect(upd.details).toEqual(['Verifikationsserie: (tomt) → M'])
|
||||
|
||||
// Bank-sync churn (balance, name) is not a behandlingsregel: no event.
|
||||
const churn = auditRowToEvent(
|
||||
auditRow({
|
||||
table_name: 'cash_accounts',
|
||||
action: 'UPDATE',
|
||||
old_state: { name: 'Företagskort', ledger_account: '1931', voucher_series: 'M', balance: 100 },
|
||||
new_state: { name: 'Företagskort', ledger_account: '1931', voucher_series: 'M', balance: 250 },
|
||||
}),
|
||||
)
|
||||
expect(churn).toBeNull()
|
||||
})
|
||||
|
||||
it('categorization_templates: the learning columns never reach the report', () => {
|
||||
// The DB trigger filters these already (20260901103000 + 20260901200000);
|
||||
// the read model must not resurrect them if a row slips through, or every
|
||||
|
||||
@@ -209,6 +209,10 @@ export const AUDITED_TABLES = [
|
||||
'booking_template_library',
|
||||
'sie_imports',
|
||||
'bank_file_imports',
|
||||
// Verifikationsserie per bankkonto (audited since migration 20260902124513):
|
||||
// the per-account override outranks the per-source-type map above, so it is
|
||||
// a behandlingsregel in the same sense.
|
||||
'cash_accounts',
|
||||
] as const
|
||||
|
||||
/**
|
||||
@@ -232,7 +236,7 @@ export const GLOBAL_ACTIONS = [
|
||||
* names statically; a unit test pins it to AUDITED_TABLES / GLOBAL_ACTIONS.
|
||||
*/
|
||||
export const AUDIT_ROW_FILTER =
|
||||
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)'
|
||||
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports,cash_accounts),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)'
|
||||
|
||||
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
manual: 'Manuell',
|
||||
@@ -411,6 +415,15 @@ const MAPPING_RULE_FIELDS: Record<string, string> = {
|
||||
is_active: 'Aktiv',
|
||||
}
|
||||
|
||||
/**
|
||||
* cash_accounts columns that are behandlingsregler. Only voucher_series: the
|
||||
* trigger (20260902124513) fires on that column alone, and the read model
|
||||
* must not resurrect balance/name churn from bank sync if a row slips through.
|
||||
*/
|
||||
const CASH_ACCOUNT_FIELDS: Record<string, string> = {
|
||||
voucher_series: 'Verifikationsserie',
|
||||
}
|
||||
|
||||
const CATEGORIZATION_TEMPLATE_FIELDS: Record<string, string> = {
|
||||
counterparty_name: 'Motpart',
|
||||
// counterparty_aliases deliberately absent: aliases grow in the same
|
||||
@@ -1128,6 +1141,14 @@ export function auditRowToEvent(
|
||||
fields: BOOKING_TEMPLATE_FIELDS,
|
||||
objectKeys: ['name'],
|
||||
})
|
||||
case 'cash_accounts':
|
||||
return genericAuditEvent(row, {
|
||||
category: 'installningar',
|
||||
codePrefix: 'cash_account',
|
||||
noun: 'Bankkonto',
|
||||
fields: CASH_ACCOUNT_FIELDS,
|
||||
objectKeys: ['name', 'ledger_account'],
|
||||
})
|
||||
case 'salary_payroll_config':
|
||||
return payrollConfigAuditEvent(row)
|
||||
// The import tables emit their own events from the rows themselves; the
|
||||
@@ -1584,7 +1605,7 @@ async function fetchAuditRows(
|
||||
// Literal on purpose (not AUDIT_ROW_FILTER): the schema guard only
|
||||
// resolves string literals here. A test pins the two to each other.
|
||||
.or(
|
||||
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)',
|
||||
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports,cash_accounts),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)',
|
||||
)
|
||||
.order('created_at', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
|
||||
@@ -1546,6 +1546,23 @@ async function buildSystemDoc(
|
||||
fiscal_period_id: vs.fiscal_period_id ?? null,
|
||||
})
|
||||
),
|
||||
// BFNAR 2013:2 p. 9.2-9.15: how verifikationer are assigned to a series
|
||||
// is a behandlingsregel the systemdokumentation has to spell out, incl.
|
||||
// the exceptions. The concrete mappings live in the data tables named
|
||||
// here (company_settings.default_voucher_series_per_source_type and
|
||||
// cash_accounts.voucher_series); changes to both are in behandlingshistorik.
|
||||
verifikationsserier_regler: {
|
||||
ordning: [
|
||||
'Serie vald av användaren i bokföringsdialogen',
|
||||
'Bankkontots egen verifikationsserie (data/cash_accounts.json, fältet voucher_series), gäller verifikat som skapas från banktransaktioner',
|
||||
'Standardserie per verifikattyp (data/company_settings.json, fältet default_voucher_series_per_source_type)',
|
||||
'Serie A',
|
||||
],
|
||||
undantag: [
|
||||
'Betalningar av kund- och leverantörsfakturor som matchas mot en banktransaktion använder fakturatypens serie, inte bankkontots',
|
||||
'Samlingsverifikat (bokföring av flera banktransaktioner i ett verifikat) använder standardserien för banktransaktioner',
|
||||
],
|
||||
},
|
||||
behorighetskontroll: {
|
||||
description: 'Rollbaserad atkomstkontroll med owner/admin/member/viewer',
|
||||
mfa_stod: true,
|
||||
|
||||
+10
-1
@@ -2335,7 +2335,16 @@
|
||||
"series_prefix": "Series",
|
||||
"default_badge": "default",
|
||||
"latest_number": "Latest no.",
|
||||
"footnote": "New series are created automatically the first time they are used in bookkeeping."
|
||||
"footnote": "New series are created automatically the first time they are used in bookkeeping.",
|
||||
"per_account_heading": "Voucher series per bank account",
|
||||
"per_account_help": "Let a bank account book into its own series, for example the main bank on A and the company card on M. Default follows the bank-transaction series above. Applies to booking bank transactions; invoice matches keep their payment series.",
|
||||
"per_account_follow_default": "Default",
|
||||
"per_account_loading": "Loading bank accounts",
|
||||
"per_account_empty": "No active bank accounts yet. Accounts are created when you connect a bank or import transactions.",
|
||||
"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"
|
||||
},
|
||||
"settings_team_panel": {
|
||||
"role_owner": "Owner",
|
||||
|
||||
+10
-1
@@ -2335,7 +2335,16 @@
|
||||
"series_prefix": "Serie",
|
||||
"default_badge": "standard",
|
||||
"latest_number": "Senaste nr",
|
||||
"footnote": "Nya serier skapas automatiskt första gången de används vid bokföring."
|
||||
"footnote": "Nya serier skapas automatiskt första gången de används vid bokföring.",
|
||||
"per_account_heading": "Verifikationsserier per bankkonto",
|
||||
"per_account_help": "Låt ett bankkonto bokföra i en egen serie, till exempel huvudbanken på A och företagskortet på M. Standard följer serien för banktransaktioner ovan. Gäller bokföring av banktransaktioner; fakturamatchningar behåller sin betalningsserie.",
|
||||
"per_account_follow_default": "Standard",
|
||||
"per_account_loading": "Hämtar bankkonton",
|
||||
"per_account_empty": "Inga aktiva bankkonton ännu. Konton skapas när du kopplar en bank eller importerar transaktioner.",
|
||||
"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"
|
||||
},
|
||||
"settings_team_panel": {
|
||||
"role_owner": "Ägare",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Verifikationsserie per bankkonto.
|
||||
--
|
||||
-- A company that runs several bank accounts (main bank on series A, a
|
||||
-- company-card account on series M, both imported via CSV) wants entries
|
||||
-- booked from each account to land in that account's own series. Until now
|
||||
-- every bank-transaction booking took the single company-wide default from
|
||||
-- company_settings.default_voucher_series_per_source_type.bank_transaction.
|
||||
--
|
||||
-- NULL means "no override": the engine keeps resolving the series from the
|
||||
-- per-source-type map, so existing accounts and bookings are untouched.
|
||||
-- Single uppercase letter, same rule as journal_entries.voucher_series.
|
||||
ALTER TABLE public.cash_accounts
|
||||
ADD COLUMN IF NOT EXISTS voucher_series text
|
||||
CHECK (voucher_series IS NULL OR voucher_series ~ '^[A-Z]$');
|
||||
|
||||
COMMENT ON COLUMN public.cash_accounts.voucher_series IS
|
||||
'Optional verifikationsserie (single letter A-Z) for entries booked from this bank account. NULL = follow the per-source-type default in company_settings.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Behandlingshistorik for the per-bankkonto verifikationsserie.
|
||||
--
|
||||
-- cash_accounts.voucher_series (20260902121420) takes precedence over the
|
||||
-- company-wide default_voucher_series_per_source_type map, which is already
|
||||
-- audited and reported as a behandlingsregel (BFNAR 2013:2 p. 9.16 second
|
||||
-- paragraph: changes that affect how bokföringsposter are processed, and when
|
||||
-- they were introduced). The override must leave the same dated trace.
|
||||
--
|
||||
-- UPDATE only, and only when the series itself changes: cash_accounts rows are
|
||||
-- created and touched by bank sync (balances, names, enabled flags) many times
|
||||
-- a day, and none of that is a behandlingsregel. Same WHEN pattern as the
|
||||
-- categorization_templates learning filter in 20260901103000.
|
||||
DROP TRIGGER IF EXISTS audit_cash_accounts_voucher_series ON public.cash_accounts;
|
||||
CREATE TRIGGER audit_cash_accounts_voucher_series
|
||||
AFTER UPDATE ON public.cash_accounts
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.voucher_series IS DISTINCT FROM NEW.voucher_series)
|
||||
EXECUTE FUNCTION public.write_audit_log();
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { insertAuthUser, insertCompany, insertCompanyMember } from './fixtures'
|
||||
import { insertAuthUser, insertCashAccount, insertCompany, insertCompanyMember } from './fixtures'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
|
||||
/**
|
||||
@@ -128,6 +128,40 @@ describe('behandlingshistorik audit triggers (BFNAR 2013:2 p. 9.16)', () => {
|
||||
expect(rows).toEqual([{ action: 'INSERT', company_id: companyId, user_id: userId }])
|
||||
})
|
||||
|
||||
it('logs a cash_accounts voucher_series change but not bank-sync churn (20260902124513)', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
const cashAccountId = await insertCashAccount({ companyId, ledgerAccount: '1931' })
|
||||
|
||||
// Bank sync touches balance and name all day: no behandlingsregel, no row.
|
||||
await getPool().query(
|
||||
`UPDATE public.cash_accounts SET balance = 250, name = 'Företagskort' WHERE id = $1`,
|
||||
[cashAccountId],
|
||||
)
|
||||
expect(await auditActions('cash_accounts', cashAccountId)).toEqual([])
|
||||
|
||||
// Assigning the account its own verifikationsserie is a behandlingsregel.
|
||||
await getPool().query(`UPDATE public.cash_accounts SET voucher_series = 'M' WHERE id = $1`, [cashAccountId])
|
||||
// Clearing it back to "follow the per-type default" is one too.
|
||||
await getPool().query(`UPDATE public.cash_accounts SET voucher_series = NULL WHERE id = $1`, [cashAccountId])
|
||||
|
||||
const rows = await getPool().query<{ action: string; company_id: string | null; old_series: string | null; new_series: string | null }>(
|
||||
`SELECT action, company_id, old_state->>'voucher_series' AS old_series, new_state->>'voucher_series' AS new_series
|
||||
FROM public.audit_log WHERE table_name = 'cash_accounts' AND record_id = $1
|
||||
ORDER BY created_at, id`,
|
||||
[cashAccountId],
|
||||
)
|
||||
expect(rows.rows).toEqual([
|
||||
{ action: 'UPDATE', company_id: companyId, old_series: null, new_series: 'M' },
|
||||
{ action: 'UPDATE', company_id: companyId, old_series: 'M', new_series: null },
|
||||
])
|
||||
|
||||
// The CHECK from 20260902121420 still guards the column.
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.cash_accounts SET voucher_series = 'ab' WHERE id = $1`, [cashAccountId]),
|
||||
).rejects.toThrow(/check constraint/i)
|
||||
})
|
||||
|
||||
it('logs the global payroll constants without a company (read via service role by the report)', async () => {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
|
||||
@@ -647,6 +647,10 @@ export interface CashAccount {
|
||||
enabled: boolean
|
||||
is_primary: boolean
|
||||
source: CashAccountSource
|
||||
// Optional verifikationsserie (single letter) for entries booked from this
|
||||
// account. null = follow company_settings.default_voucher_series_per_source_type.
|
||||
// See 20260902121420_cash_accounts_voucher_series.sql.
|
||||
voucher_series: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user