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:
co-authored by
Claude Fable 5
parent
d9dddba682
commit
5769e35869
@@ -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',
|
||||
|
||||
+63
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user