* 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>
144 lines
5.5 KiB
TypeScript
144 lines
5.5 KiB
TypeScript
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()
|
|
})
|
|
})
|