fix(api-v1): propagate underlag when booking via v1 categorize routes (#1564)

The v1 categorize and batch-categorize routes create the journal entry
via createTransactionJournalEntry directly and never ran the shared
underlag propagation, so a booking made through the API-key surface
left the transaction's pinned document unanchored and matched inbox
items unstamped: the same "Underlag saknas" gap #1560 closed for the
dashboard, /book and bulk-book paths, surviving on this one surface.

Both routes now call propagateUnderlagForBookedTransaction after the
CAS write succeeds (only when this request owns the booking; skipped on
partial success and lost CAS races). Best-effort by contract, same as
every other caller: a propagation failure is logged inside the helper
and never fails the booking.

Also adds the attach-after-bulk-book unit test salvaged from the closed
duplicate PR #1559: a document pinned to a bulk-booked transaction
(verifikat anchored via transaction_voucher_links) is anchored against
the samlingsverifikat when attached after the booking.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-13 13:48:01 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d9dddba682
commit 5769e35869
6 changed files with 198 additions and 0 deletions
@@ -57,6 +57,16 @@ vi.mock('@/lib/bookkeeping/mapping-engine', async () => {
)
return { ...actual, saveUserMappingRule: vi.fn().mockResolvedValue(undefined) }
})
// Underlag propagation: mocked to assert the WIRING (called after a booking
// this request owns, skipped otherwise); the helper's own behavior (pin
// anchoring, never-steal, failure isolation) is unit-tested in
// lib/transactions/__tests__/inbox-underlag.test.ts.
const { propagateUnderlagMock } = vi.hoisted(() => ({
propagateUnderlagMock: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/transactions/inbox-underlag', () => ({
propagateUnderlagForBookedTransaction: propagateUnderlagMock,
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST } from '../route'
@@ -160,6 +170,82 @@ beforeEach(() => {
})
})
function happyPathSupabase() {
return makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: [
{
data: {
id: TX_ID,
company_id: COMPANY_ID,
date: '2026-05-12',
amount: -349.5,
currency: 'SEK',
merchant_name: 'ICA',
cash_account_id: null,
journal_entry_id: null,
},
error: null,
},
// The CAS update matches the row: this request owns the booking.
{ data: [{ id: TX_ID }], error: null },
],
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null },
})
}
describe('POST /api/v1/.../transactions/{id}/categorize underlag propagation', () => {
it('propagates underlag onto the fresh verifikat after a successful booking', async () => {
const { supabase } = happyPathSupabase()
mockServiceClient.mockReturnValue(supabase)
const res = await POST(
makeRequest({ is_business: true, category: 'expense_office' }),
routeParams(),
)
const body = await res.json()
expect(body.data.success).toBe(true)
expect(body.data.journal_entry_id).toBe('je-fresh')
expect(propagateUnderlagMock).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
TX_ID,
'je-fresh',
)
})
it('does not propagate on the partial-success path (no journal entry was created)', async () => {
const { supabase } = happyPathSupabase()
mockServiceClient.mockReturnValue(supabase)
createTxJE.mockRejectedValueOnce(new Error('transient engine failure'))
const res = await POST(
makeRequest({ is_business: true, category: 'expense_office' }),
routeParams(),
)
const body = await res.json()
expect(body.data.journal_entry_created).toBe(false)
expect(propagateUnderlagMock).not.toHaveBeenCalled()
})
it('does not propagate when the CAS race is lost (the verifikat was stornoed)', async () => {
const { supabase } = casRaceSupabase()
mockServiceClient.mockReturnValue(supabase)
const res = await POST(
makeRequest({ is_business: true, category: 'expense_office' }),
routeParams(),
)
const body = await res.json()
expect(body.error.code).toBe('TX_CATEGORIZE_RACE')
expect(propagateUnderlagMock).not.toHaveBeenCalled()
})
})
describe('POST /api/v1/.../transactions/{id}/categorize CAS race', () => {
it('documents the stranded voucher with the real voucher_gap_explanations columns when the storno fails', async () => {
const { supabase, inserts } = casRaceSupabase()
@@ -38,6 +38,7 @@ import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/m
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { collectMappingResultAccounts, findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { eventBus } from '@/lib/events'
import type {
@@ -508,6 +509,21 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Propagate the underlag onto the new verifikat: anchor the transaction's
// pinned document and stamp matched inbox items so they leave the active
// inbox. Same shared step the dashboard categorize, /book and bulk-book
// paths run; without it a v1 booking of a tx with attached underlag reads
// "Underlag saknas" forever. Best-effort by contract (logged inside),
// never fails the booking. Runs only when THIS request won the CAS write.
if (journalEntryId) {
await propagateUnderlagForBookedTransaction(
ctx.supabase,
ctx.companyId!,
txId,
journalEntryId,
)
}
try {
await eventBus.emit({
type: 'transaction.categorized',
@@ -49,6 +49,16 @@ vi.mock('@/lib/bookkeeping/account-validation', async () => {
})
// category mapping is real: gives the route real BAS accounts to validate.
// Underlag propagation: mocked to assert the wiring (called once per item
// that actually booked); behavior is unit-tested in
// lib/transactions/__tests__/inbox-underlag.test.ts.
const { propagateUnderlagMock } = vi.hoisted(() => ({
propagateUnderlagMock: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/transactions/inbox-underlag', () => ({
propagateUnderlagForBookedTransaction: propagateUnderlagMock,
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST } from '../route'
@@ -169,6 +179,59 @@ describe('POST batch-categorize', () => {
)
})
it('propagates underlag once per item that actually booked, not for already-booked items', async () => {
const txRow = (id: string, journalEntryId: string | null) => ({
data: {
id,
company_id: COMPANY_ID,
date: '2026-05-12',
amount: -100,
currency: 'SEK',
merchant_name: 'ICA',
cash_account_id: null,
journal_entry_id: journalEntryId,
},
error: null,
})
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: [
txRow(TX_A, null), // item A fetch: unbooked
{ data: [{ id: TX_A }], error: null }, // item A CAS update: owned
txRow(TX_B, 'je-old'), // item B fetch: already booked
{ data: null, error: null }, // item B flags-flip update
],
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null },
}).supabase,
)
const res = await POST(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`,
{
items: [
{ transaction_id: TX_A, categorization: { is_business: true, category: 'expense_office' } },
{ transaction_id: TX_B, categorization: { is_business: true, category: 'expense_office' } },
],
},
),
batchParams(),
)
expect(res.status).toBe(200)
// Only the item whose CAS write this batch owns gets the propagation;
// the already-booked item was consumed by whatever booked it earlier.
expect(propagateUnderlagMock).toHaveBeenCalledTimes(1)
expect(propagateUnderlagMock).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
TX_A,
'je-fresh',
)
})
it('isolates a settlement lookup failure to its item and continues the batch', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
@@ -31,6 +31,7 @@ import { recordVoucherGapExplanation } from '@/lib/bookkeeping/cancel-orphaned-e
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { collectMappingResultAccounts, findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { eventBus } from '@/lib/events'
import type { Logger } from '@/lib/logger'
@@ -431,6 +432,15 @@ async function categorizeOne(
}
}
// Propagate the underlag onto the new verifikat: anchor the transaction's
// pinned document and stamp matched inbox items. Same shared step as the
// single :categorize route and every dashboard booking path; best-effort
// by contract (logged inside), never fails the item. Runs only when THIS
// item won the CAS write.
if (journalEntryId) {
await propagateUnderlagForBookedTransaction(supabase, companyId, transactionId, journalEntryId)
}
try {
await eventBus.emit({
type: 'transaction.categorized',