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:
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user