diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 44b102de..5c492b0e 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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 | 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 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 { - 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 ): Promise { 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} /> diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 6bacc733..c92f62d2 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -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 diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index c7499fc1..3cfea377 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -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, diff --git a/app/api/transactions/suggest-categories/route.ts b/app/api/transactions/suggest-categories/route.ts index 1bc5427c..5daafed4 100644 --- a/app/api/transactions/suggest-categories/route.ts +++ b/app/api/transactions/suggest-categories/route.ts @@ -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] || [] diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts index d12bae7a..2b225d0b 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts index 5e614cfc..50b9b0d0 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts @@ -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, diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index 55c82193..9a9daef4 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -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 | null onConfirm: ( id: string, category: TransactionCategory, vatTreatment: VatTreatment | undefined, accountOverride: string | undefined, - templateId?: string + templateId?: string, + dimensions?: Record ) => Promise 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(transaction) const [rateLoading, setRateLoading] = useState(false) const [rateError, setRateError] = useState(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>( + () => ({ ...(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 && ( +
+ +
+ { + setDims((prev) => { + const next = { ...prev } + if (code) next[sieDimNo] = code + else delete next[sieDimNo] + return next + }) + }} + inputClassName="h-8" + /> +
+
+ )} + {/* 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 && ( diff --git a/components/transactions/transaction-types.ts b/components/transactions/transaction-types.ts index 8e06e08a..52f05cc9 100644 --- a/components/transactions/transaction-types.ts +++ b/components/transactions/transaction-types.ts @@ -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 ) => Promise export type MatchInvoiceHandler = ( diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 2ec9e2c2..2e93d5cc 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), diff --git a/lib/bookkeeping/__tests__/counterparty-templates.test.ts b/lib/bookkeeping/__tests__/counterparty-templates.test.ts index 47196159..b227bcf4 100644 --- a/lib/bookkeeping/__tests__/counterparty-templates.test.ts +++ b/lib/bookkeeping/__tests__/counterparty-templates.test.ts @@ -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['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' }) + }) +}) diff --git a/lib/bookkeeping/counterparty-templates.ts b/lib/bookkeeping/counterparty-templates.ts index 7a06c187..ba0747c1 100644 --- a/lib/bookkeeping/counterparty-templates.ts +++ b/lib/bookkeeping/counterparty-templates.ts @@ -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 | 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, }) } diff --git a/lib/transactions/category-suggestions.ts b/lib/transactions/category-suggestions.ts index 5a04f5c3..0bf37404 100644 --- a/lib/transactions/category-suggestions.ts +++ b/lib/transactions/category-suggestions.ts @@ -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 | null } /** diff --git a/messages/en.json b/messages/en.json index 7ba560ee..1befbc9b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 933f81e2..08d41445 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/supabase/migrations/20260729101000_categorization_template_default_dimensions.sql b/supabase/migrations/20260729101000_categorization_template_default_dimensions.sql new file mode 100644 index 00000000..552a4d2f --- /dev/null +++ b/supabase/migrations/20260729101000_categorization_template_default_dimensions.sql @@ -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'; diff --git a/types/index.ts b/types/index.ts index 4e40b47c..34455eb9 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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 occurrence_count: number confidence: number last_seen_date: string | null