feat(categorize): dimension bags end-to-end + runtime template learning (#1273)

* feat(categorize): dimension bags end-to-end + runtime template learning

The categorize path could not tag: categorize-core accepted a dimensions
bag but no route or UI ever passed one, and runtime template learning
dropped the bag entirely (only SIE import produced dimension-carrying
patterns).

- CategorizeTransactionSchema gains dimensions; the dashboard route, v1
  single and v1 batch-categorize apply it to the mapping result's
  business lines (explicit bag wins over a learned counterparty bag).
- categorization_templates.default_dimensions (migration 20260728091000)
  records the bag of the latest tagged booking; latest-explicit-wins, an
  untagged booking never erases it. Applied on the legacy single-line
  template path and the mirrored-refund path; multi-line SIE patterns
  keep their authoritative per-entry bags.
- QuickReviewDialog gets a LineDimensionFields picker (dimensions_enabled
  gate, same as BulkBookDialog), prefilled from the counterparty
  suggestion's learned bag; hidden for multi-line patterns whose per-line
  bags would ignore an edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Renumber migration above 20260728120000 (out-of-order vs prod after #1271)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-29 09:39:23 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 951b33363b
commit 222e581476
16 changed files with 403 additions and 9 deletions
@@ -282,6 +282,61 @@ describe('POST /api/transactions/[id]/categorize', () => {
)
})
it('passes body.dimensions onto the mapping result the engine books', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
merchant_name: 'GitHub',
journal_entry_id: null,
})
// Fresh copy: the route mutates the mapping result in place, and the
// shared defaultMappingResult object would leak dimensions across tests.
mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult })
enqueue({ data: tx, error: null }) // fetch transaction
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings
enqueue({ data: [{ id: 'period-1' }], error: null }) // fiscal period check
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: [{ id: 'tx-1' }], error: null }) // tx update (CAS matched)
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: {
is_business: true,
category: 'expense_software',
dimensions: { '1': 'KS1', '6': 'P001' },
},
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockCreateTransactionJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'tx-1' }),
expect.objectContaining({ dimensions: { '1': 'KS1', '6': 'P001' } }),
)
})
it('rejects a malformed dimensions bag with 400', async () => {
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: {
is_business: true,
category: 'expense_software',
// Key must be a SIE dim number: 'projekt' is not.
dimensions: { projekt: 'P001' },
},
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
})
it('flags an inbox underlag matched to the transaction as booked', async () => {
// A document was attached to this transaction in the inbox
// (matched_transaction_id) but not booked from there. Booking the
@@ -382,6 +382,14 @@ export const POST = withRouteContext(
}
}
// Dimensions: an explicitly picked bag tags the business lines of the
// generated verifikat (bank/VAT legs stay untagged, see
// buildTransactionEntryLines). It wins over a learned counterparty-
// template bag; omitted = the learned bag (if any) applies unchanged.
if (body.dimensions && Object.keys(body.dimensions).length > 0) {
mappingResult.dimensions = body.dimensions
}
if (!mappingResult.debit_account || !mappingResult.credit_account) {
return errorResponseFromCode('TX_CATEGORIZE_INVALID_MAPPING', txLog, {
requestId,
@@ -100,6 +100,10 @@ export const POST = withRouteContext(
risk_level: 'NONE',
requires_review: false,
line_pattern: tmpl.line_pattern ?? null,
default_dimensions:
tmpl.default_dimensions && Object.keys(tmpl.default_dimensions).length > 0
? tmpl.default_dimensions
: null,
}
const existing = template_suggestions[tx.id] || []
@@ -295,6 +295,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
}
// Dimensions: an explicitly supplied bag tags the business lines of the
// generated verifikat (bank/VAT legs stay untagged). Wins over a learned
// counterparty-template bag; omitted = the learned bag (if any) applies.
if (body.dimensions && Object.keys(body.dimensions).length > 0) {
mappingResult.dimensions = body.dimensions
}
if (!mappingResult.debit_account || !mappingResult.credit_account) {
return v1ErrorResponseFromCode('TX_CATEGORIZE_INVALID_MAPPING', txLog, {
requestId: ctx.requestId,
@@ -187,6 +187,11 @@ async function categorizeOne(
input.vat_treatment,
)
}
// Dimensions: an explicitly supplied bag tags the business lines of the
// generated verifikat (bank/VAT legs stay untagged).
if (input.dimensions && Object.keys(input.dimensions).length > 0) {
mappingResult.dimensions = input.dimensions
}
if (!mappingResult.debit_account || !mappingResult.credit_account) {
return {
ok: false,