* 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>
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
const mockFrom = vi.fn()
|
|
const mockAuth = vi.fn()
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: vi.fn().mockResolvedValue({
|
|
from: (...args: unknown[]) => mockFrom(...args),
|
|
auth: { getUser: () => mockAuth() },
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
import { GET } from '../route'
|
|
|
|
interface ChainResult {
|
|
data?: unknown
|
|
error?: unknown
|
|
}
|
|
|
|
function mockChain(result: ChainResult) {
|
|
const chain: Record<string, unknown> = {}
|
|
for (const m of ['select', 'eq', 'lte', 'gte']) {
|
|
chain[m] = vi.fn().mockReturnValue(chain)
|
|
}
|
|
chain.maybeSingle = vi.fn().mockResolvedValue(result)
|
|
return chain
|
|
}
|
|
|
|
function mkReq(query = '') {
|
|
return new Request(`http://localhost/api/bookkeeping/voucher-sequences/next${query}`)
|
|
}
|
|
|
|
function mkParams() {
|
|
return { params: Promise.resolve({}) }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('GET /api/bookkeeping/voucher-sequences/next', () => {
|
|
it('returns 401 when not authenticated', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: null } })
|
|
|
|
const response = await GET(mkReq(), mkParams())
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(401)
|
|
expect(body.error).toBe('Unauthorized')
|
|
})
|
|
|
|
it('returns last_number + 1 when sequence exists', 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' }, error: null })
|
|
}
|
|
if (table === 'voucher_sequences') {
|
|
return mockChain({ data: { last_number: 57 }, error: null })
|
|
}
|
|
throw new Error(`Unexpected table: ${table}`)
|
|
})
|
|
|
|
const response = await GET(mkReq(), mkParams())
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body.data).toEqual({ next: 58, series: 'A', fiscal_period_id: 'period-1' })
|
|
})
|
|
|
|
it('returns 1 when no sequence row exists yet', 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: null, error: null })
|
|
}
|
|
if (table === 'voucher_sequences') {
|
|
return mockChain({ data: null, error: null })
|
|
}
|
|
throw new Error(`Unexpected table: ${table}`)
|
|
})
|
|
|
|
const response = await GET(mkReq(), mkParams())
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body.data).toEqual({ next: 1, series: 'A', fiscal_period_id: 'period-1' })
|
|
})
|
|
|
|
it('returns next: null when no fiscal period covers today', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
|
|
|
mockFrom.mockImplementation((table: string) => {
|
|
if (table === 'fiscal_periods') {
|
|
return mockChain({ data: null, error: null })
|
|
}
|
|
if (table === 'company_settings') {
|
|
return mockChain({ data: { default_voucher_series: 'V' }, error: null })
|
|
}
|
|
throw new Error(`Unexpected table: ${table}`)
|
|
})
|
|
|
|
const response = await GET(mkReq(), mkParams())
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body.data).toEqual({ next: null, series: 'V', fiscal_period_id: null })
|
|
})
|
|
|
|
it('honors a non-default voucher series from company settings', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
|
|
|
mockFrom.mockImplementation((table: string) => {
|
|
if (table === 'fiscal_periods') {
|
|
return mockChain({ data: { id: 'period-2' }, error: null })
|
|
}
|
|
if (table === 'company_settings') {
|
|
return mockChain({ data: { default_voucher_series: 'V' }, error: null })
|
|
}
|
|
if (table === 'voucher_sequences') {
|
|
return mockChain({ data: { last_number: 12 }, error: null })
|
|
}
|
|
throw new Error(`Unexpected table: ${table}`)
|
|
})
|
|
|
|
const response = await GET(mkReq(), mkParams())
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body.data).toEqual({ next: 13, series: 'V', fiscal_period_id: 'period-2' })
|
|
})
|
|
|
|
it('resolves the series per source_type, matching the booking engine', 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: { invoice_cash_payment: 'V' },
|
|
},
|
|
error: null,
|
|
})
|
|
}
|
|
if (table === 'voucher_sequences') {
|
|
return mockChain({ data: { last_number: 4 }, error: null })
|
|
}
|
|
throw new Error(`Unexpected table: ${table}`)
|
|
})
|
|
|
|
const response = await GET(mkReq('?source_type=invoice_cash_payment'), mkParams())
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
// Uses the per-source-type map (V), NOT the global default (A).
|
|
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' } } })
|
|
|
|
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: 'V',
|
|
default_voucher_series_per_source_type: { invoice_paid: 'B' },
|
|
},
|
|
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=invoice_cash_payment'), mkParams())
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
// No mapping for invoice_cash_payment → engine-matching fallback of 'A'
|
|
// (the global default is intentionally NOT used here, no consolidation).
|
|
expect(body.data).toEqual({ next: 1, series: 'A', fiscal_period_id: 'period-1' })
|
|
})
|
|
|
|
it('rejects an unknown source_type with 400 before touching the database', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
|
|
|
const response = await GET(mkReq('?source_type=not_a_source_type'), mkParams())
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(mockFrom).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects a malformed date with 400 before touching the database', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
|
|
|
const response = await GET(mkReq('?date=2026-13-99x'), mkParams())
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(mockFrom).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects a malformed series with 400 before touching the database', async () => {
|
|
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
|
|
|
const response = await GET(mkReq('?series=AB'), mkParams())
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(mockFrom).not.toHaveBeenCalled()
|
|
})
|
|
})
|