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:
co-authored by
Claude Fable 5.1
parent
678acfe7ef
commit
f1230282a9
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user