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
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,
+76 -2
View File
@@ -17,6 +17,7 @@ import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
@@ -38,12 +39,19 @@ interface QuickReviewDialogProps {
template?: BookingTemplate | null
templateId?: string
counterpartyLinePattern?: LinePatternEntry[] | null
/**
* Learned bag from the counterparty template (default_dimensions): prefills
* the picker so the user sees, and can change, what the booking will be
* tagged with.
*/
counterpartyDefaultDimensions?: Record<string, string> | null
onConfirm: (
id: string,
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
accountOverride: string | undefined,
templateId?: string
templateId?: string,
dimensions?: Record<string, string>
) => Promise<string | null>
onChangeTemplate?: () => void
}
@@ -60,6 +68,7 @@ export default function QuickReviewDialog({
template,
templateId,
counterpartyLinePattern,
counterpartyDefaultDimensions,
onConfirm,
onChangeTemplate,
}: QuickReviewDialogProps) {
@@ -81,6 +90,14 @@ export default function QuickReviewDialog({
const [enrichedTx, setEnrichedTx] = useState<TransactionWithInvoice | null>(transaction)
const [rateLoading, setRateLoading] = useState(false)
const [rateError, setRateError] = useState<string | null>(null)
// Dimension tagging (kostnadsställe/projekt): the picker renders only when
// company_settings.dimensions_enabled, same gate as BulkBookDialog. Seeded
// from the counterparty template's learned bag so the user sees what the
// booking will carry and can change it.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [dims, setDims] = useState<Record<string, string>>(
() => ({ ...(counterpartyDefaultDimensions ?? {}) }),
)
const preAttachedDocumentId = transaction?.document_id ?? null
@@ -113,8 +130,28 @@ export default function QuickReviewDialog({
useEffect(() => {
setEnrichedTx(transaction)
setRateError(null)
setDims({ ...(counterpartyDefaultDimensions ?? {}) })
// Re-seeding on counterpartyDefaultDimensions alone would clobber in-
// flight edits; the bag only changes together with the transaction.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transaction])
// Company settings gate the dimension affordance (dimensions_enabled).
// Fetched once per open; on failure the picker simply stays hidden.
useEffect(() => {
if (!open) return
let cancelled = false
fetch('/api/settings')
.then((r) => r.json())
.then(({ data }) => {
if (!cancelled) setDimensionsEnabled(data?.dimensions_enabled === true)
})
.catch(() => {
if (!cancelled) setDimensionsEnabled(false)
})
return () => { cancelled = true }
}, [open])
// Backfill the SEK conversion on demand. resolveSekAmount silently falls
// back to the raw foreign amount when amount_sek/exchange_rate are null,
// which means the user would see misleading "kr" values in the verifikation
@@ -203,7 +240,20 @@ export default function QuickReviewDialog({
? accountOverride
: undefined
const journalEntryId = await onConfirm(transaction.id, category, resolvedVat, override, templateId)
// Cleared combobox values leave empty strings behind; strip them so an
// untouched picker sends no bag at all (learned template bags then apply
// server-side unchanged).
const cleanedDims = Object.fromEntries(
Object.entries(dims).filter(([, code]) => code && code.trim().length > 0),
)
const journalEntryId = await onConfirm(
transaction.id,
category,
resolvedVat,
override,
templateId,
Object.keys(cleanedDims).length > 0 ? cleanedDims : undefined,
)
// Attach the uploaded underlag to the verifikat the booking just created.
// BFL 5 kap 7 § requires the verifikation to reference its underlag and
@@ -464,6 +514,30 @@ export default function QuickReviewDialog({
</>
)}
{/* Dimension tags (kostnadsställe/projekt): rendered for category,
library-template and legacy counterparty bookings. Multi-line
counterparty patterns are excluded: their per-line bags are
authoritative server-side and an edit here would be ignored. */}
{dimensionsEnabled && !isCounterpartyTemplate && (
<div>
<label className="text-sm font-medium text-muted-foreground">{t('label_dimensions')}</label>
<div className="mt-1">
<LineDimensionFields
dimensions={dims}
onChange={(sieDimNo, code) => {
setDims((prev) => {
const next = { ...prev }
if (code) next[sieDimNo] = code
else delete next[sieDimNo]
return next
})
}}
inputClassName="h-8"
/>
</div>
</div>
)}
{/* No pre-attached document: let the user upload one. (When a document
IS pre-attached it's shown in the left preview column instead.) */}
{!preAttachedDocumentId && (
+3 -1
View File
@@ -20,7 +20,9 @@ export type CategorizeHandler = (
vatTreatment?: VatTreatment,
accountOverride?: string,
templateId?: string,
inboxItemId?: string
inboxItemId?: string,
// Dimensions bag {sie_dim_no: code} for the business lines of the booking.
dimensions?: Record<string, string>
) => Promise<string | null>
export type MatchInvoiceHandler = (
+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
}
/**
+1
View File
@@ -2417,6 +2417,7 @@
"change_template": "Change template",
"reverse_charge_warning": "Reverse charge requires the supplier's VAT registration number and country.",
"label_account": "Account",
"label_dimensions": "Dimensions",
"label_vat_treatment": "VAT treatment",
"no_vat_liability_account": "No VAT for liability/equity accounts",
"no_vat_default": "No VAT",
+1
View File
@@ -2417,6 +2417,7 @@
"change_template": "Byt mall",
"reverse_charge_warning": "Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.",
"label_account": "Konto",
"label_dimensions": "Dimensioner",
"label_vat_treatment": "Momsbehandling",
"no_vat_liability_account": "Ingen moms för skuld-/eget kapital-konton",
"no_vat_default": "Ingen moms",
@@ -0,0 +1,30 @@
-- Counterparty templates learn dimension tags from runtime bookings.
--
-- categorization_templates.default_dimensions holds the {sie_dim_no: code}
-- bag the user last booked this counterparty with. The legacy single-line
-- template path applies it to the business line of the next suggested
-- booking (an explicit user-picked bag still wins); multi-line SIE-learned
-- patterns keep carrying per-entry bags in line_pattern and ignore this
-- column by design (per-line bags are authoritative there).
--
-- Update semantics are latest-explicit-wins: a booking WITH a bag replaces
-- the stored bag, a booking without one leaves it untouched (an untagged
-- booking is not evidence the user stopped using dimensions).
--
-- Same shape + CHECK as journal_entry_lines.dimensions (20260702084500).
-- NOT NULL DEFAULT '{}' is metadata-only on PG11+ (no table rewrite).
--
-- pg-test: covered-by — plain column add with a type CHECK, no
-- trigger/RPC/RLS/DEFERRABLE change. Learning/apply logic is TS-side
-- (lib/bookkeeping/counterparty-templates.ts unit tests).
ALTER TABLE public.categorization_templates
ADD COLUMN default_dimensions jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE public.categorization_templates
ADD CONSTRAINT categorization_templates_default_dimensions_is_object
CHECK (jsonb_typeof(default_dimensions) = 'object');
COMMENT ON COLUMN public.categorization_templates.default_dimensions IS
'Dimension bag {sie_dim_no: code} learned from the latest tagged booking of this counterparty; applied to the business line when the template is booked via the legacy single-line path. See lib/bookkeeping/counterparty-templates.ts.';
NOTIFY pgrst, 'reload schema';
+4
View File
@@ -1820,6 +1820,10 @@ export interface CategorizationTemplate {
vat_account: string | null
category: TransactionCategory | null
line_pattern: LinePatternEntry[] | null
// Bag {sie_dim_no: code} learned from the latest tagged booking; applied to
// the business line on the legacy single-line template path (line_pattern
// entries carry their own bags on the multi-line path).
default_dimensions?: Record<string, string>
occurrence_count: number
confidence: number
last_seen_date: string | null