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:
co-authored by
Claude Fable 5
parent
951b33363b
commit
222e581476
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user