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
+4
View File
@@ -1293,6 +1293,10 @@ export const CategorizeTransactionSchema = z
vat_treatment: VatTreatmentSchema.optional(),
account_override: accountNumber.optional(),
counterparty_template_id: z.string().uuid().optional(),
// Dimensions bag {sie_dim_no: code} applied to the business lines of the
// generated verifikat (bank/VAT legs stay untagged). Wins over a learned
// counterparty-template bag when both are present.
dimensions: DimensionsBagSchema.optional(),
user_description: z.string().max(500).optional(),
inbox_item_id: z.string().uuid().optional(),
confirm_no_match: z.boolean().optional(),
@@ -1576,3 +1576,159 @@ describe('learning-loop repair (issue #865)', () => {
})
})
})
// ── Runtime dimension learning (default_dimensions) ─────────────────────────
describe('counterparty template default_dimensions', () => {
/**
* The queued mock's chain proxy discards call args by design, so capture
* .insert/.update payloads with a thin wrapper around the original
* implementation.
*/
function captureWrites(supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']) {
const writes: { insert: unknown[]; update: unknown[] } = { insert: [], update: [] }
const originalFrom = supabase.from.getMockImplementation()!
supabase.from.mockImplementation((table: string) => {
const chain = originalFrom(table) as object
return new Proxy(chain, {
get(target, prop, receiver) {
if (prop === 'insert' || prop === 'update') {
return (rows: unknown) => {
writes[prop].push(rows)
return (Reflect.get(target, prop, receiver) as (r: unknown) => unknown)(rows)
}
}
return Reflect.get(target, prop, receiver)
},
})
})
return writes
}
const baseMappingResult = {
rule: null,
debit_account: '5410',
credit_account: '1930',
risk_level: 'NONE' as const,
confidence: 0.9,
requires_review: false,
default_private: false,
vat_lines: [],
description: 'Test',
}
it('learns the booked bag on a fresh template insert', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const writes = captureWrites(supabase)
const tx = makeTransaction({ merchant_name: 'Spotify AB', date: '2026-07-01' })
enqueue({ data: null }) // no existing template
enqueue({ data: null }) // insert ok
await upsertCounterpartyTemplate(
supabase as never,
'company-1',
tx,
{ ...baseMappingResult, dimensions: { '6': 'P001' } },
'user_approved',
)
expect(writes.insert).toHaveLength(1)
expect(writes.insert[0]).toMatchObject({ default_dimensions: { '6': 'P001' } })
})
it('replaces the stored bag when a re-approval carries a new one', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const writes = captureWrites(supabase)
const existing = makeCategorizationTemplate({
debit_account: '5410',
credit_account: '1930',
occurrence_count: 3,
})
const tx = makeTransaction({ merchant_name: 'telia', date: '2026-07-01' })
enqueue({ data: existing })
enqueue({ data: null }) // update ok
await upsertCounterpartyTemplate(
supabase as never,
'company-1',
tx,
{ ...baseMappingResult, dimensions: { '6': 'P002' } },
'user_approved',
)
expect(writes.update).toHaveLength(1)
expect(writes.update[0]).toMatchObject({ default_dimensions: { '6': 'P002' } })
})
it('an untagged booking never erases the learned bag', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const writes = captureWrites(supabase)
const existing = makeCategorizationTemplate({
debit_account: '5410',
credit_account: '1930',
occurrence_count: 3,
})
const tx = makeTransaction({ merchant_name: 'telia', date: '2026-07-01' })
enqueue({ data: existing })
enqueue({ data: null }) // update ok
await upsertCounterpartyTemplate(
supabase as never,
'company-1',
tx,
baseMappingResult, // no dimensions
'user_approved',
)
expect(writes.update).toHaveLength(1)
expect(writes.update[0]).not.toHaveProperty('default_dimensions')
})
it('legacy single-line template match applies the learned bag', () => {
const template = makeCategorizationTemplate({
debit_account: '6200',
credit_account: '1930',
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS1', '6': 'P001' },
})
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 }
const tx = makeTransaction({ amount: -1250 })
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
expect(result.dimensions).toEqual({ '1': 'KS1', '6': 'P001' })
})
it('a template without a learned bag sets no dimensions', () => {
const template = makeCategorizationTemplate({
debit_account: '6200',
credit_account: '1930',
})
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 }
const tx = makeTransaction({ amount: -1250 })
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
expect(result).not.toHaveProperty('dimensions')
})
it('a mirrored refund keeps the learned bag so the reversal reduces the same project', () => {
const template = makeCategorizationTemplate({
debit_account: '6200',
credit_account: '1930',
vat_treatment: 'standard_25',
default_dimensions: { '6': 'P001' },
})
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 }
// Expense-learned template, incoming money: mirrored, review-gated.
const tx = makeTransaction({ amount: 1250 })
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
expect(result.direction_mismatch).toBe(true)
expect(result.dimensions).toEqual({ '6': 'P001' })
})
})
+28
View File
@@ -544,6 +544,11 @@ export function buildMappingResultFromCounterpartyTemplate(
),
default_private: isPrivate,
vat_lines: vatLines,
// Learned bag tags the business line (buildTransactionEntryLines); an
// explicitly supplied bag on the categorize call overwrites it afterwards.
...(tmpl.default_dimensions && Object.keys(tmpl.default_dimensions).length > 0
? { dimensions: tmpl.default_dimensions }
: {}),
description: `Motpart: ${tmpl.counterparty_name} (${tmpl.occurrence_count} ggr)`,
}
}
@@ -600,6 +605,11 @@ function buildLegacyMismatchResult(
direction_mismatch: true,
default_private: false,
vat_lines: vatLines,
// A refund of a tagged expense reduces the same kostnadsställe/projekt,
// mirroring how the multi-line path keeps entry bags when mirrored.
...(tmpl.default_dimensions && Object.keys(tmpl.default_dimensions).length > 0
? { dimensions: tmpl.default_dimensions }
: {}),
description: `Motpart: ${tmpl.counterparty_name} (retur/återbetalning)`,
}
}
@@ -718,6 +728,13 @@ export interface TemplateUpsertParams {
lastSeenDate: string | null
source: CategorizationTemplateSource
linePattern?: LinePatternEntry[] | null
/**
* Bag {sie_dim_no: code} from the booking being learned. Latest-explicit-
* wins: a non-empty bag replaces the stored default_dimensions, an empty/
* omitted bag leaves it untouched (an untagged booking is not evidence the
* user stopped tagging this counterparty).
*/
defaultDimensions?: Record<string, string> | null
}
/**
@@ -812,6 +829,9 @@ export async function insertOrUpdateTemplate(
source: newSource,
counterparty_aliases: mergedAliases,
line_pattern: params.linePattern !== undefined ? params.linePattern : existing.line_pattern,
...(params.defaultDimensions && Object.keys(params.defaultDimensions).length > 0
? { default_dimensions: params.defaultDimensions }
: {}),
})
.eq('id', existing.id)
if (error) {
@@ -833,6 +853,9 @@ export async function insertOrUpdateTemplate(
counterparty_aliases: mergedAliases,
category: params.category || existing.category,
...(params.linePattern !== undefined ? { line_pattern: params.linePattern } : {}),
...(params.defaultDimensions && Object.keys(params.defaultDimensions).length > 0
? { default_dimensions: params.defaultDimensions }
: {}),
})
.eq('id', existing.id)
if (error) {
@@ -853,6 +876,7 @@ export async function insertOrUpdateTemplate(
vat_account: params.vatAccount,
category: params.category,
line_pattern: params.linePattern ?? null,
default_dimensions: params.defaultDimensions ?? {},
occurrence_count: params.occurrenceCount,
confidence: params.confidence,
last_seen_date: params.lastSeenDate,
@@ -904,6 +928,10 @@ export async function upsertCounterpartyTemplate(
confidence: calculateConfidence(1),
lastSeenDate: transaction.date,
source,
// The bag the booking actually carried (user-picked or template-applied).
// Latest-explicit-wins inside insertOrUpdateTemplate: empty bags never
// erase a learned one.
defaultDimensions: mappingResult.dimensions ?? null,
})
}
+4
View File
@@ -247,6 +247,10 @@ export interface SuggestedTemplate {
risk_level: string
requires_review: boolean
line_pattern?: LinePatternEntry[] | null
// Learned {sie_dim_no: code} bag on counterparty suggestions: prefills the
// review dialog's dimension picker (the server applies it at booking anyway;
// surfacing it keeps the user in the loop).
default_dimensions?: Record<string, string> | null
}
/**