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
+17 -6
View File
@@ -205,6 +205,8 @@ interface QuickReviewState {
template: BookingTemplate | null
templateId: string | undefined
linePattern: LinePatternEntry[] | null
// Learned counterparty bag; prefills the review dialog's dimension picker.
defaultDimensions?: Record<string, string> | null
}
export default function TransactionsPage() {
@@ -910,8 +912,8 @@ export default function TransactionsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transactions.length])
const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId) => {
return runCategorize({ id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, confirmNoMatch: false })
const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, dimensions) => {
return runCategorize({ id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, dimensions, confirmNoMatch: false })
}
async function runCategorize(args: {
@@ -922,6 +924,7 @@ export default function TransactionsPage() {
accountOverride?: string
templateId?: string
inboxItemId?: string
dimensions?: Record<string, string>
confirmNoMatch: boolean
// Set after the user confirms the booking-time duplicate warning. force
// bypasses the guard; the bypass is bound to the reviewed candidate's
@@ -930,7 +933,7 @@ export default function TransactionsPage() {
force?: boolean
expectedDuplicateJournalEntryId?: string
}): Promise<string | null> {
const { id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, confirmNoMatch, force, expectedDuplicateJournalEntryId } = args
const { id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, dimensions, confirmNoMatch, force, expectedDuplicateJournalEntryId } = args
try {
setProcessingId(id)
const response = await fetch(`/api/transactions/${id}/categorize`, {
@@ -943,6 +946,7 @@ export default function TransactionsPage() {
account_override: accountOverride,
template_id: templateId,
inbox_item_id: inboxItemId,
...(dimensions && Object.keys(dimensions).length > 0 ? { dimensions } : {}),
...(confirmNoMatch ? { confirm_no_match: true } : {}),
...(force && expectedDuplicateJournalEntryId
? { force: true, expected_duplicate_journal_entry_id: expectedDuplicateJournalEntryId }
@@ -2162,6 +2166,7 @@ export default function TransactionsPage() {
template: { id: templateId, name_sv: cpSuggestion.name_sv } as BookingTemplate,
templateId: undefined,
linePattern: cpSuggestion.line_pattern ?? null,
defaultDimensions: cpSuggestion.default_dimensions ?? null,
})
setQuickReviewOpen(true)
return
@@ -2213,7 +2218,8 @@ export default function TransactionsPage() {
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
accountOverride: string | undefined,
templateId?: string
templateId?: string,
dimensions?: Record<string, string>
): Promise<string | null> {
let journalEntryId: string | null
if (!templateId && quickReview?.template?.id && isCounterpartyTemplateId(quickReview.template.id)) {
@@ -2222,7 +2228,11 @@ export default function TransactionsPage() {
const r = await fetch(`/api/transactions/${id}/categorize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_business: true, counterparty_template_id: cpTemplateId }),
body: JSON.stringify({
is_business: true,
counterparty_template_id: cpTemplateId,
...(dimensions && Object.keys(dimensions).length > 0 ? { dimensions } : {}),
}),
})
const b = await r.json()
return { ok: r.ok, status: r.status, result: b, journalEntryId: b?.journal_entry_id || null }
@@ -2303,7 +2313,7 @@ export default function TransactionsPage() {
setExitingIds((prev) => new Set(prev).add(id))
journalEntryId = cpJeId
} else {
journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId)
journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId, undefined, dimensions)
}
// Always close: whether the server created a verifikation, returned a
// structured 4xx (ACCOUNTS_NOT_IN_CHART, INVALID_MAPPING, …), or hit a
@@ -2793,6 +2803,7 @@ export default function TransactionsPage() {
template={quickReview?.template ?? null}
templateId={quickReview?.templateId}
counterpartyLinePattern={quickReview?.linePattern ?? null}
counterpartyDefaultDimensions={quickReview?.defaultDimensions ?? null}
onConfirm={handleQuickReviewConfirm}
onChangeTemplate={handleChangeTemplate}
/>
@@ -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,