From 5769e35869e7e9216d53da45ecaad3e3aff9e1d0 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:48:01 +0200 Subject: [PATCH] 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 --- DECISIONS.md | 1 + .../[id]/categorize/__tests__/route.test.ts | 86 +++++++++++++++++++ .../transactions/[id]/categorize/route.ts | 16 ++++ .../batch-categorize/__tests__/route.test.ts | 63 ++++++++++++++ .../transactions/batch-categorize/route.ts | 10 +++ .../__tests__/inbox-underlag.test.ts | 22 +++++ 6 files changed, 198 insertions(+) diff --git a/DECISIONS.md b/DECISIONS.md index ca3d87d4..288a1310 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -897,3 +897,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] Underlag failures resolve the response where it fails (getResponseErrorMessage + status) and an expired session announces itself on the existing session-timeout BroadcastChannel, instead of a global authenticated-fetch wrapper: `throw new Error(json.error)` printed "[object Object]" for the structured envelope, so the middleware 401 on a backgrounded mobile tab surfaced as the generic "NĂ¥got gick fel" with no way back; a wrapper would have to be threaded through every call site to buy the same thing here. Upload failures also post metadata (status, size, mime, resolved reason) to /api/log, the one API path exempt from the timeout gate, because a request answered before the route runs leaves nothing in the function logs and the user-reported failures were invisible there. [2026-08-13] Oversized phone photos are re-encoded in the browser (2400px long edge, JPEG q0.85 stepping down) rather than raising a platform limit or streaming straight to storage: hosted rejects any request body over 4.5 MB itself (measured against prod: 4.4 MB reaches the route, 4.6 MB returns a plain-text FUNCTION_PAYLOAD_TOO_LARGE), before the route runs and therefore invisibly in the function logs, while the route advertises 10 MB it can never receive. A downscaled photo is still a faithful, durably readable reproduction (BFL 7 kap), which a refusal is not. What cannot be shrunk (PDF, or HEIC where the browser will not decode it) is refused client-side with its actual size named, and 413 was added to the HTTP status map so a rejection in transit still says what happened. Direct-to-storage upload, which would remove the ceiling for PDFs too, is the follow-up, not this fix: it moves sha256/WORM integrity off the server. [2026-08-13] The book-route underlag fix landed as a pinned-document leg inside propagateUnderlagForBookedTransaction rather than the planned "extract categorize-core's propagation block into a shared helper": PR #1547 had already done that extraction overnight and wired /book and bulk-book to the shared helper, but the helper only walked matched inbox items, so a document pinned via transactions.document_id with no unconsumed inbox item (direct upload, or item consumed elsewhere) still booked to "Underlag saknas". Anchoring the pin inside the helper fixes /book, categorize, bulk-book and attach-after-book in one place; the pin is read fresh (not from the caller's pre-booking snapshot) so a concurrent attach still anchors, and the bulk-book RPC's own atomic doc-linking makes the leg a no-op there. +[2026-08-13] v1 categorize/batch-categorize wire the shared underlag propagation after the CAS write rather than inlining anchoring logic, and the route tests mock the helper to assert wiring only (called once per booking the request owns; skipped on partial success and lost CAS races): the helper's own semantics (pin anchoring, never-steal, failure isolation) are unit-tested where they live, and duplicating them at route level is what let the v1 surface drift out of the #1560 fix in the first place. Salvaged from the closed duplicate PR #1559: the attach-after-bulk-book samlingsverifikat test. diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/__tests__/route.test.ts index 075c5d40..003eee25 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/__tests__/route.test.ts @@ -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() diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts index 2b225d0b..4093e4f8 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts @@ -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', diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts index d75dc9ed..4f66d322 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts @@ -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({ diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts index 4b56f3eb..2e34f44f 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts @@ -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', diff --git a/lib/transactions/__tests__/inbox-underlag.test.ts b/lib/transactions/__tests__/inbox-underlag.test.ts index 16bcac2f..513c31d4 100644 --- a/lib/transactions/__tests__/inbox-underlag.test.ts +++ b/lib/transactions/__tests__/inbox-underlag.test.ts @@ -333,4 +333,26 @@ describe('completeInboxItemsForBookedTransaction', () => { { created_journal_entry_id: JE2 }, ]) }) + + it('anchors a pinned document against the samlingsverifikat on attach-after-bulk-book', async () => { + // A bulk-booked tx keeps transactions.journal_entry_id null: its verifikat + // hangs off transaction_voucher_links. A document attached AFTER that + // booking (with no inbox item) must still land on the samlingsverifikat: + // voucher-link resolution feeds the pin leg of the propagation. + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: [{ transaction_id: TX1, journal_entry_id: JE2 }] }) // voucher links + enqueue({ data: { document_id: 'doc-pin' } }) // tx pin lookup + enqueue({ data: { journal_entry_id: null } }) // pinned doc unanchored + enqueue({ data: [] }) // no matched inbox items + + const result = await completeInboxItemsForBookedTransaction( + supabase as unknown as SupabaseClient, + COMPANY, + TX1, + { directJournalEntryId: null }, + ) + expect(result).toBe(JE2) + expect(linkToJournalEntry).toHaveBeenCalledWith(expect.anything(), COMPANY, 'doc-pin', JE2) + expect(findCalls('invoice_inbox_items', 'update')).toEqual([]) + }) })