From 650c7be5e1ec4377477c4b0a01313dded5d58a64 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:13:50 +0200 Subject: [PATCH] fix(bookkeeping): revive counterparty template learning (dead since the multi-tenant refactor) (#989) * fix(bookkeeping): revive counterparty template learning, dead since the multi-tenant refactor (#865) The learning half of counterparty templates has written nothing since 2026-03-30 (prod: 750 SIE imports, zero new templates). Two stacked bugs: - The multi-tenant refactor re-scoped categorization_templates to company_id and the lib stopped writing user_id, but user_id kept its NOT NULL: every insert failed with a null violation that supabase-js returns rather than throws, so nothing was ever logged. Migration 20260711100000 drops the NOT NULL and the dead user_id indexes. - Four of six learning call sites (both categorize routes, categorize-core, the MCP server) passed the auth user id as companyId, so even with the column fixed the writes would fail FK/RLS and corrections could never find the template they were correcting. Hardening while in here: - insertOrUpdateTemplate now checks every write result, logs failures, and returns whether a row was written; populateTemplatesFromSieVouchers reports only templates actually persisted. - Sign-mismatched matches (an incoming refund matching an expense-learned template) previously booked backwards: debit expense / credit bank for money coming IN. They are now mirrored into the correct refund shape (VAT leg reversed for deductible input VAT), flagged requires_review, and excluded from template/rule learning so a refund can never flip a learned template. - Template amounts are computed from the SEK-resolved amount, so foreign-currency transactions no longer produce unbalanced multi-line entries (or VAT computed on foreign units). - SIE extraction no longer hardcodes 25% for 2641 (rate-agnostic in BAS): the rate is inferred from voucher amounts and snapped to 25/12/6%, and reverse-charge counterparties learn vat_treatment='reverse_charge' instead of losing the RC legs (which also no longer poison the ratio base). - New pg-real test locks the exact insert column set against the real schema, so a schema/code drift like this can't ship green again. Closes #865 Co-Authored-By: Claude Fable 5 * fix(bookkeeping): mirror fiktiv-moms legs on RC credit notes, exclude import VAT accounts from ratio base Compliance-review follow-ups on #989: - REVERSE_CHARGE_VAT_ACCOUNTS gains the import output-VAT accounts (2615/2625/2635), which pair with 2645 in import vouchers exactly like the RC pairs and must not shrink the business ratio base. - A sign-mismatched match against a reverse_charge template (an RC supplier's credit note) now mirrors both fiktiv legs (credit 2645 / debit 2614) instead of booking gross, so Ruta 30/48 net back to zero. The income line-builder nets VAT credits against debit legs to keep the mirrored pair balance-neutral (identical result for all existing credit-only output-VAT paths). Co-Authored-By: Claude Fable 5 * fix(types): CategorizationTemplate.user_id is nullable since 20260711100000 (CodeRabbit) Co-Authored-By: Claude Fable 5 * fix(bookkeeping): use roundOre for the VAT netting, keep the ore-round ratchet at baseline Co-Authored-By: Claude Fable 5 * fix(bookkeeping): review-gate stale 12% templates across the livsmedel transition, pattern-aware direction guard Compliance-review round 2 on #989: - Livsmedel VAT dropped 12% -> 6% on 2026-04-01 (Prop. 2025/26:55) while restaurang/hotell stay at 12%. A reduced_12 template whose last_seen_date predates the transition can no longer be trusted unreviewed: its match is flagged requires_review until a post-transition approval refreshes it (re-approval keeps 12%, a correction relearns 6%). Actively-confirmed 12% counterparties flow without friction. - The opposite-direction correction guard now falls back to the line pattern's business sides when the legacy fields are both settlement-ish and cannot classify a multi-line template. - Documented the accepted import-RC mirroring limitation (2614 vs 2615 ruta attribution) and the netted-vatCredit precondition. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 3 + app/api/transactions/[id]/categorize/route.ts | 8 +- .../transactions/[id]/categorize/route.ts | 7 +- extensions/general/mcp-server/server.ts | 2 +- .../__tests__/counterparty-templates.test.ts | 539 ++++++++++++++++++ lib/bookkeeping/counterparty-templates.ts | 315 +++++++++- lib/bookkeeping/transaction-entries.ts | 13 +- lib/transactions/categorize-core.ts | 2 +- ..._categorization_templates_learning_fix.sql | 25 + ...tegorization-templates-learning.pg.test.ts | 70 +++ types/index.ts | 9 +- 11 files changed, 950 insertions(+), 43 deletions(-) create mode 100644 supabase/migrations/20260711100000_categorization_templates_learning_fix.sql create mode 100644 tests/pg/categorization-templates-learning.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 58eba0df..a10c4d08 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -63,3 +63,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-11] Momsrapport after settlement (#984): extended the VAT-report exclusion from tag-only to shape-based. Any entry touching both a declaration account (ACCOUNT_RUTA) and a settlement net account (2650/1650) is treated as a momsredovisning and excluded from the projection (web calculateVatDeclaration + MCP computeVatReport), covering manual momsomforingar booked before #980 shipped, SIE-imported settlements, and stornos of a settlement (which would otherwise double the rutor after annullera, a latent bug in the #983 tag-only filter). Opening-balance entries are exempt from the shape rule: carried-in 26xx balances are unsettled VAT that belongs in the next declaration. Shaped POSTED entries also gate the "Skapa verifikat" button via existing_entries (the proposal re-clears the full period, so booking over a manual settlement would corrupt 26xx); stornos never gate, or annullera could not re-enable booking. Rejected the frozen-snapshot alternative the issue suggested: pure projection heals historical periods retroactively (a snapshot would not exist for them) and needs no migration. [2026-07-11] #984 shape-rule residuals triaged and ACCEPTED (compliance-bot review): a compound verifikat mixing business VAT lines with a 2650/1650 payment/correction line in ONE entry is excluded from the rutor by the shape rule (under-reports). Kept anyway: such compound entries are rare bad practice, and the suggested direction guard (only exclude when 2650 is credited / 1650 debited) would break the storno exclusion, whose reversal carries exactly the flipped sides. Opening-balance concern verified false for app flows: SIE import and set_opening_balances both tag source_type 'opening_balance' (sie-import.ts); only a hand-booked IB verifikat shares the compound-entry residual. [2026-07-11] Paywall conversion pass (Mobbin paywall research applied): (1) checkout now passes subscription_data.trial_end (trial grant expiry, only when >49h out per Stripe's 48h floor) so a mid-trial upgrade charges 0 kr at checkout instead of double-billing days the company already has free; the subscription starts 'trialing', which subscription-sync already treats as access-granting, and billing/status now counts 'trialing' as isPaying (card committed = manage view). (2) Trial countdown became a sidebar touchpoint (CompanyContext.trialEndsAt via getCompanyEntitlements, hidden for sandbox and once any non-trial grant is active) instead of living only inside Inställningar → Abonnemang. (3) Sell view: honest what-happens-when timeline + free-vs-paid comparison table + risk-reversal copy under the CTA. Deliberately NOT copied from the research: fake urgency, last-minute discounts, spin-the-wheel, card-required-to-trial: trust-first product, and the free tier (freeze-and-retain) is a strategic choice, not a leak. External price anchoring ("costs less than an accountant hour") skipped: unverifiable claim. Billing components stay hardcoded Swedish per the file's existing convention. +[2026-07-11] Counterparty template learning repair (#865): fixed the dead write path with ALTER COLUMN user_id DROP NOT NULL (kept the column and its data; a column drop is a separate cleanup) instead of re-plumbing user_id through the insert, because scoping is company_id-only since the multi-tenant refactor and RLS never reads user_id. Sign-mismatched matches (refund against an expense-learned template) are MIRRORED + requires_review rather than skipped: the swapped entry (debit bank / credit expense, VAT leg reversed) is the bookkeeping-correct refund shape, and skipping would just fall through to the dumb default; direction_mismatch results and opposite-direction "corrections" never write back into the template so a refund cannot flip the learned accounts. SIE extraction infers the 2641 VAT rate from voucher amounts (snap to 25/12/6% within 1.5pp, else drop the VAT leg) only when the voucher has exactly one deductible-VAT line: with several, each line's base is unknowable and the old 25% hardcode stays. +[2026-07-11] Counterparty template follow-ups from the compliance-bot review of PR #989: RC exclusion set extended with import output-VAT accounts (2615/2625/2635); RC credit notes now mirror both fiktiv legs (credit 2645 / debit 2614, income line-builder nets VAT credits against debits) so Ruta 30/48 net to zero instead of leaving the prior RC output unreversed. The 1.5pp snap tolerance is derived from the smallest gap between legal rates (6pp between 6% and 12%): 1.5pp accepts ore-rounding drift on small vouchers while an ambiguous observed rate (e.g. 9%) snaps to nothing and drops the VAT leg. Livsmedel 12->6% transition (April 2026): templates store the LEARNED rate, so one applied to a backdated pre-transition purchase books the new rate; accepted, the booking is review-visible and re-deriving rate-by-date is out of scope here. +[2026-07-11] Compliance-review round 2 on PR #989: (1) stale reduced_12 templates are review-gated across the livsmedel 12->6% transition (verified via swedish-vat skill: food dropped 2026-04-01, restaurang/hotell stay 12%) only when last_seen_date predates the transition, so actively-confirmed 12% counterparties keep flowing while pre-transition grocery templates get a human look; chose this over the bot's blanket flag-all-reduced_12 (too much friction) and over rate-by-date re-derivation (needs a rates table keyed on statute dates, out of scope). (2) Import-RC credit-note mirroring books the reversal on 2614 rather than 2615 (ruta 30 vs 60 attribution): accepted as-is because the entry balances, is review-gated, and the FORWARD legacy path has the same limitation (legacy fields cannot carry which output account history used); proper fix is persisting the learned RC output account, filed as future work. diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index d57b57aa..76a9abb5 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -663,7 +663,9 @@ export const POST = withRouteContext( } } - if (is_business && transaction.merchant_name) { + // direction_mismatch = a mirrored refund/repayment booking; learning it + // as a rule would store backwards accounts for the merchant. + if (is_business && transaction.merchant_name && !mappingResult.direction_mismatch) { try { await saveUserMappingRule( supabase, @@ -681,8 +683,10 @@ export const POST = withRouteContext( } try { + // Templates are company-scoped since the multi-tenant refactor: passing + // user.id here broke learning entirely (FK/RLS reject the write). await upsertCounterpartyTemplate( - supabase, user.id, transaction as Transaction, mappingResult, 'user_approved', + supabase, companyId, transaction as Transaction, mappingResult, 'user_approved', ) } catch (err) { txLog.warn('failed to upsert counterparty template (non-critical)', err as Error) 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 82527486..65c5fa94 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts @@ -380,8 +380,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string // Best-effort: save mapping rule + upsert counterparty template. These // are user-experience polish (faster future categorization) and never - // fail the request. - if (is_business && transaction.merchant_name) { + // fail the request. direction_mismatch = a mirrored refund/repayment + // booking; learning it as a rule would store backwards accounts. + if (is_business && transaction.merchant_name && !mappingResult.direction_mismatch) { try { await saveUserMappingRule( ctx.supabase, @@ -400,7 +401,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string try { await upsertCounterpartyTemplate( ctx.supabase, - ctx.userId, + ctx.companyId!, transaction as Transaction, mappingResult, 'user_approved', diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 708bfab2..cbbe8669 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -769,7 +769,7 @@ async function categorizeTransactionCore( // Upsert counterparty template for future auto-matching try { await upsertCounterpartyTemplate( - supabase, userId, transaction as Transaction, mappingResult, 'user_approved' + supabase, companyId, transaction as Transaction, mappingResult, 'user_approved' ) } catch { // Non-critical diff --git a/lib/bookkeeping/__tests__/counterparty-templates.test.ts b/lib/bookkeeping/__tests__/counterparty-templates.test.ts index 6038097f..03314208 100644 --- a/lib/bookkeeping/__tests__/counterparty-templates.test.ts +++ b/lib/bookkeeping/__tests__/counterparty-templates.test.ts @@ -16,6 +16,8 @@ import { insertOrUpdateTemplate, populateTemplatesFromSieVouchers, } from '../counterparty-templates' +import { buildTransactionEntryLines } from '../transaction-entries' +import { roundOre } from '@/lib/money' import type { TemplateUpsertParams } from '../counterparty-templates' import type { LinePatternEntry } from '@/types' import type { SIETransactionLine } from '@/lib/import/types' @@ -896,3 +898,540 @@ describe('dimensions propagation (PR7)', () => { }) }) }) + +// ── learning-loop repair (issue #865) ──────────────────────── + +describe('learning-loop repair (issue #865)', () => { + /** Queue-based mock that records insert payloads per table (see PR7 block). */ + function createCapturingSupabase(results: Array<{ data?: unknown; error?: unknown }>) { + const queue = [...results] + const inserts: Record = {} + + const from = vi.fn((_table: string) => { + const raw = queue.shift() ?? { data: null, error: null } + const result = { data: raw.data ?? null, error: raw.error ?? null } + const chain: object = new Proxy( + {}, + { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(result) + } + if (prop === 'insert') { + return (payload: unknown) => { + ;(inserts[_table] ??= []).push(payload) + return chain + } + } + return () => chain + }, + }, + ) + return chain + }) + + return { supabase: { from }, inserts } + } + + function capturedTemplateRow(inserts: Record) { + const rows = inserts['categorization_templates'] + expect(rows).toHaveLength(1) + return rows[0] as { + vat_treatment: string | null + vat_account: string | null + line_pattern: LinePatternEntry[] | null + debit_account: string + credit_account: string + } + } + + describe('direction mismatch (refunds/repayments)', () => { + it('mirrors an expense-learned template for an incoming refund, with reversed input VAT', () => { + const template = makeCategorizationTemplate({ + debit_account: '6200', + credit_account: '1930', + vat_treatment: 'standard_25', + }) + const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 } + const tx = makeTransaction({ amount: 1250 }) // money IN from an expense counterparty + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma') + + // Mirrored: settle on debit (bank), reduce the expense on credit + expect(result.debit_account).toBe('1930') + expect(result.credit_account).toBe('6200') + expect(result.requires_review).toBe(true) + expect(result.direction_mismatch).toBe(true) + // Input VAT mirrored: credit 2641 for the VAT share of 1250 = 250 + expect(result.vat_lines).toHaveLength(1) + expect(result.vat_lines[0].account_number).toBe('2641') + expect(result.vat_lines[0].credit_amount).toBe(250) + expect(result.vat_lines[0].debit_amount).toBe(0) + }) + + it('mirrors both fiktiv-moms legs for a reverse-charge credit note, and the entry balances', () => { + const template = makeCategorizationTemplate({ + counterparty_name: 'google cloud emea', + debit_account: '4535', + credit_account: '1930', + vat_treatment: 'reverse_charge', + vat_account: '2645', + }) + const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 } + const tx = makeTransaction({ amount: 5000 }) // credit note from an RC supplier + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'aktiebolag') + + expect(result.direction_mismatch).toBe(true) + // Original purchase books debit 2645 / credit 2614; the mirror flips both + const in2645 = result.vat_lines.find((l) => l.account_number === '2645') + const out2614 = result.vat_lines.find((l) => l.account_number === '2614') + expect(in2645?.credit_amount).toBe(1250) + expect(out2614?.debit_amount).toBe(1250) + + // End-to-end: the RC pair nets to zero, business keeps the gross amount, + // and the whole entry balances + const lines = buildTransactionEntryLines(tx, result) + const debits = roundOre(lines.reduce((s, l) => s + l.debit_amount, 0)) + const credits = roundOre(lines.reduce((s, l) => s + l.credit_amount, 0)) + expect(debits).toBe(credits) + expect(lines.find((l) => l.account_number === '4535')?.credit_amount).toBe(5000) + }) + + it('mirrors an income-learned template for an outgoing repayment, without VAT lines', () => { + const template = makeCategorizationTemplate({ + counterparty_name: 'kund ab', + debit_account: '1930', + credit_account: '3001', + vat_treatment: null, + vat_account: null, + }) + const match = { template, matchMethod: 'exact_normalized' as const, confidence: 0.8 } + const tx = makeTransaction({ amount: -5000 }) // money OUT to an income counterparty + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'aktiebolag') + + expect(result.debit_account).toBe('3001') + expect(result.credit_account).toBe('1930') + expect(result.requires_review).toBe(true) + expect(result.direction_mismatch).toBe(true) + expect(result.vat_lines).toHaveLength(0) + }) + + it('mirrors every side of a multi-line pattern on sign mismatch', () => { + const template = makeCategorizationTemplate({ + debit_account: '5410', + credit_account: '1930', + line_pattern: [ + { account: '2641', type: 'vat', side: 'debit', vat_rate: 0.25 }, + { account: '5410', type: 'business', side: 'debit', ratio: 1 }, + ], + }) + const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 } + const tx = makeTransaction({ amount: 1250 }) // refund of a multi-line expense + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma') + + expect(result.all_lines_complete).toBe(true) + expect(result.requires_review).toBe(true) + expect(result.direction_mismatch).toBe(true) + expect(result.debit_account).toBe('1930') + expect(result.credit_account).toBe('5410') + const vat = result.vat_lines.find((l) => l.account_number === '2641') + expect(vat?.credit_amount).toBe(250) + expect(vat?.debit_amount).toBe(0) + const business = result.vat_lines.find((l) => l.account_number === '5410') + expect(business?.credit_amount).toBe(1000) + expect(business?.debit_amount).toBe(0) + }) + + it('does not learn from a direction-mismatched booking', async () => { + const { supabase } = createQueuedMockSupabase() + const tx = makeTransaction({ merchant_name: 'Telia Sverige AB', amount: 1250 }) + + await upsertCounterpartyTemplate( + supabase as never, + 'company-1', + tx, + { + rule: null, + debit_account: '1930', + credit_account: '6200', + risk_level: 'NONE', + confidence: 0.85, + requires_review: true, + direction_mismatch: true, + default_private: false, + vat_lines: [], + description: 'Motpart: telia (retur/återbetalning)', + }, + 'user_approved', + ) + + expect(supabase.from).not.toHaveBeenCalled() + }) + + it('refuses an opposite-direction "correction" of an existing template', async () => { + const { supabase } = createQueuedMockSupabase() + const existing = makeCategorizationTemplate({ + debit_account: '6200', + credit_account: '1930', + }) + + const params: TemplateUpsertParams = { + counterpartyName: 'telia', + aliases: ['telia refund'], + debitAccount: '1930', // refund shape: settlement moved to debit + creditAccount: '3990', + vatTreatment: null, + vatAccount: null, + category: null, + occurrenceCount: 1, + confidence: 0.45, + lastSeenDate: '2024-07-01', + source: 'user_approved', + } + + const written = await insertOrUpdateTemplate(supabase as never, 'company-1', params, existing) + + expect(written).toBe(false) + expect(supabase.from).not.toHaveBeenCalled() + }) + + it('falls back to the pattern direction when the legacy fields cannot classify a multi-line template', async () => { + const { supabase } = createQueuedMockSupabase() + // Both legacy fields settlement-ish → legacy direction 'unknown'; + // the pattern's business sides say expense. + const existing = makeCategorizationTemplate({ + debit_account: '2440', + credit_account: '1930', + line_pattern: [ + { account: '2641', type: 'vat', side: 'debit', vat_rate: 0.25 }, + { account: '5410', type: 'business', side: 'debit', ratio: 1 }, + ], + }) + + const params: TemplateUpsertParams = { + counterpartyName: 'telia', + aliases: [], + debitAccount: '1930', // income-shaped: opposite of the learned pattern + creditAccount: '3001', + vatTreatment: null, + vatAccount: null, + category: null, + occurrenceCount: 1, + confidence: 0.45, + lastSeenDate: '2024-07-01', + source: 'user_approved', + } + + const written = await insertOrUpdateTemplate(supabase as never, 'company-1', params, existing) + + expect(written).toBe(false) + expect(supabase.from).not.toHaveBeenCalled() + }) + + it('still applies a same-direction account correction', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const existing = makeCategorizationTemplate({ + debit_account: '6200', + credit_account: '1930', + }) + enqueue({ data: null }) // update + + const params: TemplateUpsertParams = { + counterpartyName: 'telia', + aliases: [], + debitAccount: '6230', // still expense-shaped + creditAccount: '1930', + vatTreatment: 'standard_25', + vatAccount: '2641', + category: null, + occurrenceCount: 1, + confidence: 0.45, + lastSeenDate: '2024-07-01', + source: 'user_approved', + } + + const written = await insertOrUpdateTemplate(supabase as never, 'company-1', params, existing) + + expect(written).toBe(true) + expect(supabase.from).toHaveBeenCalledWith('categorization_templates') + }) + }) + + describe('reduced_12 templates across the livsmedel transition (2026-04-01)', () => { + const staleTemplate = () => makeCategorizationTemplate({ + debit_account: '4010', + credit_account: '1930', + vat_treatment: 'reduced_12', + last_seen_date: '2026-03-10', // learned before the 12→6 livsmedel change + }) + + it('review-gates a stale 12% template applied after the transition', () => { + const match = { template: staleTemplate(), matchMethod: 'exact_alias' as const, confidence: 0.85 } + const tx = makeTransaction({ amount: -560, date: '2026-05-02' }) + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma') + + expect(result.requires_review).toBe(true) + expect(result.direction_mismatch).toBeUndefined() + }) + + it('does not gate a 12% template re-confirmed after the transition', () => { + const template = makeCategorizationTemplate({ + debit_account: '5831', // e.g. restaurant/hotel: legitimately still 12% + credit_account: '1930', + vat_treatment: 'reduced_12', + last_seen_date: '2026-04-20', + }) + const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 } + const tx = makeTransaction({ amount: -560, date: '2026-05-02' }) + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma') + + expect(result.requires_review).toBe(false) + }) + + it('does not gate pre-transition transaction dates or other rates', () => { + const match = { template: staleTemplate(), matchMethod: 'exact_alias' as const, confidence: 0.85 } + const backdated = makeTransaction({ amount: -560, date: '2026-03-20' }) + expect( + buildMappingResultFromCounterpartyTemplate(match, backdated, 'enskild_firma').requires_review + ).toBe(false) + + const t25 = makeCategorizationTemplate({ + vat_treatment: 'standard_25', + last_seen_date: '2026-03-10', + }) + const match25 = { template: t25, matchMethod: 'exact_alias' as const, confidence: 0.85 } + const tx = makeTransaction({ amount: -560, date: '2026-05-02' }) + expect( + buildMappingResultFromCounterpartyTemplate(match25, tx, 'enskild_firma').requires_review + ).toBe(false) + }) + + it('review-gates a stale multi-line pattern carrying a 12% VAT entry', () => { + const template = makeCategorizationTemplate({ + debit_account: '4010', + credit_account: '1930', + vat_treatment: null, + last_seen_date: '2026-02-01', + line_pattern: [ + { account: '2641', type: 'vat', side: 'debit', vat_rate: 0.12 }, + { account: '4010', type: 'business', side: 'debit', ratio: 1 }, + ], + }) + const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 } + const tx = makeTransaction({ amount: -1120, date: '2026-05-02' }) + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma') + + expect(result.requires_review).toBe(true) + }) + }) + + describe('SEK resolution for foreign-currency transactions', () => { + it('computes legacy VAT lines from the SEK amount, not the foreign amount', () => { + const template = makeCategorizationTemplate({ + debit_account: '6200', + credit_account: '1930', + vat_treatment: 'standard_25', + }) + const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 } + const tx = makeTransaction({ + amount: -100, + currency: 'EUR', + amount_sek: -1100, + exchange_rate: 11, + }) + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma') + + // VAT share of 1100 SEK at 25% = 220, not 20 (which the EUR amount gives) + expect(result.vat_lines[0].debit_amount).toBe(220) + }) + + it('computes multi-line pattern amounts from the SEK amount so the entry balances', () => { + const template = makeCategorizationTemplate({ + debit_account: '5410', + credit_account: '1930', + line_pattern: [ + { account: '2641', type: 'vat', side: 'debit', vat_rate: 0.25 }, + { account: '5410', type: 'business', side: 'debit', ratio: 1 }, + ], + }) + const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 } + const tx = makeTransaction({ + amount: -100, + currency: 'EUR', + amount_sek: -1100, + exchange_rate: 11, + }) + + const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma') + + const vat = result.vat_lines.find((l) => l.account_number === '2641') + const business = result.vat_lines.find((l) => l.account_number === '5410') + expect(vat?.debit_amount).toBe(220) + expect(business?.debit_amount).toBe(880) + // Non-settlement lines sum to the SEK settlement amount: entry balances + const totalDebits = result.vat_lines.reduce((s, l) => s + l.debit_amount, 0) + expect(totalDebits).toBe(1100) + }) + }) + + describe('SIE VAT-rate inference (2641 is rate-agnostic)', () => { + it('learns reduced_12 from voucher amounts instead of hardcoding 25%', async () => { + const vouchers = Array.from({ length: 3 }, (_, i) => + makeSIEVoucher({ + description: 'Restaurang AB', + number: i + 1, + lines: [ + { account: '1930', amount: -1120 }, + { account: '6072', amount: 1000 }, + { account: '2641', amount: 120 }, // 12% of the 1000 net + ], + }) + ) + + const { supabase, inserts } = createCapturingSupabase([ + { data: [] }, // pre-fetch existing templates + { data: null }, // insert + ]) + + const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers) + expect(count).toBe(1) + + const row = capturedTemplateRow(inserts) + expect(row.vat_treatment).toBe('reduced_12') + expect(row.vat_account).toBe('2641') + }) + + it('drops the VAT leg when the observed rate snaps to no legal rate', async () => { + const vouchers = Array.from({ length: 3 }, (_, i) => + makeSIEVoucher({ + description: 'Utlandet Ltd', + number: i + 1, + lines: [ + { account: '1930', amount: -1190 }, + { account: '6100', amount: 1000 }, + { account: '2641', amount: 190 }, // 19%: not a Swedish rate + ], + }) + ) + + const { supabase, inserts } = createCapturingSupabase([ + { data: [] }, + { data: null }, + ]) + + const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers) + expect(count).toBe(1) + + const row = capturedTemplateRow(inserts) + expect(row.vat_treatment).toBeNull() + expect(row.vat_account).toBeNull() + }) + + it('excludes import output-VAT accounts (2615) from the ratio base like other fiktiv moms', async () => { + const vouchers = Array.from({ length: 3 }, (_, i) => + makeSIEVoucher({ + description: 'Import Goods Ltd', + number: i + 1, + lines: [ + { account: '1930', amount: -1000 }, + { account: '4545', amount: 1000 }, + { account: '2645', amount: 250 }, + { account: '2615', amount: -250 }, // output VAT on imports + ], + }) + ) + + const { supabase, inserts } = createCapturingSupabase([ + { data: [] }, + { data: null }, + ]) + + const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers) + expect(count).toBe(1) + + const row = capturedTemplateRow(inserts) + expect(row.vat_treatment).toBe('reverse_charge') + expect(row.debit_account).toBe('4545') + // Simple pattern: neither fiktiv leg shrank the business ratio base + expect(row.line_pattern).toBeNull() + }) + + it('learns reverse_charge for counterparties booked with fiktiv moms', async () => { + const vouchers = Array.from({ length: 3 }, (_, i) => + makeSIEVoucher({ + description: 'Google Cloud EMEA', + number: i + 1, + lines: [ + { account: '1930', amount: -1000 }, + { account: '4535', amount: 1000 }, + { account: '2645', amount: 250 }, + { account: '2614', amount: -250 }, + ], + }) + ) + + const { supabase, inserts } = createCapturingSupabase([ + { data: [] }, + { data: null }, + ]) + + const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers) + expect(count).toBe(1) + + const row = capturedTemplateRow(inserts) + expect(row.vat_treatment).toBe('reverse_charge') + expect(row.vat_account).toBe('2645') + expect(row.debit_account).toBe('4535') + expect(row.credit_account).toBe('1930') + // Simple pattern: RC legs are regenerated from the treatment at booking + expect(row.line_pattern).toBeNull() + }) + }) + + describe('write-failure visibility', () => { + it('populateTemplatesFromSieVouchers counts only rows actually written', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const vouchers = Array.from({ length: 3 }, (_, i) => + makeSIEVoucher({ + description: 'Telia', + number: i + 1, + lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }], + }) + ) + + enqueue({ data: [] }) // pre-fetch existing templates + enqueue({ error: { message: 'null value in column "user_id"' } }) // insert fails + + const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers) + expect(count).toBe(0) + }) + + it('insertOrUpdateTemplate returns false on insert error instead of swallowing it', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ error: { message: 'violates row-level security policy' } }) + + const written = await insertOrUpdateTemplate(supabase as never, 'company-1', { + counterpartyName: 'telia', + aliases: [], + debitAccount: '6200', + creditAccount: '1930', + vatTreatment: null, + vatAccount: null, + category: null, + occurrenceCount: 1, + confidence: 0.45, + lastSeenDate: '2024-06-15', + source: 'user_approved', + }, null) + + expect(written).toBe(false) + }) + }) +}) diff --git a/lib/bookkeeping/counterparty-templates.ts b/lib/bookkeeping/counterparty-templates.ts index 65c8fe04..f2ec797c 100644 --- a/lib/bookkeeping/counterparty-templates.ts +++ b/lib/bookkeeping/counterparty-templates.ts @@ -9,6 +9,8 @@ import { getVatRate, } from './vat-entries' import { dimensionsBagKey } from './dimension-resolver' +import { resolveSekAmount } from './currency-utils' +import { createLogger } from '@/lib/logger' import type { CategorizationTemplate, CategorizationTemplateSource, @@ -21,6 +23,8 @@ import type { } from '@/types' import type { SIEVoucher } from '@/lib/import/types' +const log = createLogger('counterparty-templates') + // ── Normalization ────────────────────────────────────────────── /** @@ -154,6 +158,48 @@ const VAT_ACCOUNT_TREATMENT: Record = { '2634': 'reverse_charge', } +/** + * Reverse-charge/import VAT accounts (fiktiv in-/utgående moms). These net to + * zero inside a voucher, so SIE pattern extraction must not count them as + * deductible VAT: doing so poisons the non-VAT base the business ratios are + * computed against. 2647 = domestic RC input (ML 16 kap); 2615/2625/2635 = + * output VAT on imports, paired the same way in import vouchers. + */ +const REVERSE_CHARGE_VAT_ACCOUNTS = new Set([ + '2614', '2624', '2634', '2615', '2625', '2635', '2645', '2647', +]) + +/** Legal Swedish VAT rates a learned pattern is allowed to carry. */ +const LEGAL_VAT_RATES = [0.25, 0.12, 0.06] + +/** Map a learned VAT rate back to its treatment string. */ +function rateToTreatment(rate: number): string | null { + if (rate === 0.25) return 'standard_25' + if (rate === 0.12) return 'reduced_12' + if (rate === 0.06) return 'reduced_6' + return null +} + +/** + * Livsmedel VAT dropped from 12% to 6% on 2026-04-01 (Prop. 2025/26:55); + * restaurant/hotel/camping stay at 12%. A 12% template that has not been + * confirmed since the transition may belong to either group, so its match is + * review-gated until a post-transition approval refreshes last_seen_date + * (re-approval keeps 12%, a correction relearns 6%). + */ +const REDUCED_12_TRANSITION_DATE = '2026-04-01' + +function isStaleReduced12Match( + hasReduced12: boolean, + transactionDate: string, + lastSeenDate: string | null +): boolean { + if (!hasReduced12) return false + // ISO yyyy-mm-dd strings: plain comparison is chronological + if (transactionDate < REDUCED_12_TRANSITION_DATE) return false + return !lastSeenDate || lastSeenDate < REDUCED_12_TRANSITION_DATE +} + // ── Lookup ───────────────────────────────────────────────────── export interface CounterpartyTemplateMatch { @@ -269,9 +315,43 @@ export async function findCounterpartyTemplatesBatch( // ── Build MappingResult ──────────────────────────────────────── +/** Which side of a template the money settles on. */ +type TemplateDirection = 'expense' | 'income' | 'unknown' + +/** + * Learned direction of a legacy (single debit/credit) template: expenses + * settle on the credit side (credit bank, debit cost), income settles on the + * debit side. 'unknown' when neither or both accounts look like settlement. + */ +function legacyTemplateDirection(debitAccount: string, creditAccount: string): TemplateDirection { + const debitSettles = isSettlementAccount(debitAccount) + const creditSettles = isSettlementAccount(creditAccount) + if (creditSettles && !debitSettles) return 'expense' + if (debitSettles && !creditSettles) return 'income' + return 'unknown' +} + +/** Learned direction of a multi-line pattern: read off the business sides. */ +function patternDirection(pattern: LinePatternEntry[]): TemplateDirection { + const business = pattern.filter((e) => e.type === 'business') + if (business.length === 0) return 'unknown' + const debitCount = business.filter((b) => b.side === 'debit').length + if (debitCount === business.length) return 'expense' + if (debitCount === 0) return 'income' + return 'unknown' +} + /** * Convert a counterparty template match into a MappingResult * (same shape the mapping engine expects). + * + * When the transaction's sign contradicts the template's learned direction + * (an incoming refund matching an expense-learned template, or an outgoing + * repayment matching an income-learned one), booking the template as-is + * would post backwards: debit an expense account for money coming IN. Those + * matches are mirrored instead (settle against the bank, reduce the business + * account), flagged requires_review, and marked direction_mismatch so they + * are never learned back into the template. */ export function buildMappingResultFromCounterpartyTemplate( match: CounterpartyTemplateMatch, @@ -279,15 +359,27 @@ export function buildMappingResultFromCounterpartyTemplate( _entityType: EntityType ): MappingResult { const tmpl = match.template + const isExpense = transaction.amount < 0 // Multi-line pattern path if (tmpl.line_pattern && tmpl.line_pattern.length > 0) { - return buildMultiLineMappingResult(tmpl, match, transaction) + const learned = patternDirection(tmpl.line_pattern) + const mirror = + (learned === 'expense' && !isExpense) || (learned === 'income' && isExpense) + return buildMultiLineMappingResult(tmpl, match, transaction, mirror) } - // Legacy single debit/credit path - const absAmount = Math.abs(transaction.amount) - const isExpense = transaction.amount < 0 + // Legacy single debit/credit path. + // Journal lines are always booked in SEK: compute template amounts from the + // SEK-resolved amount so foreign-currency transactions stay balanced. + const absAmount = Math.abs(resolveSekAmount( + transaction.amount, transaction.amount_sek, transaction.currency, transaction.exchange_rate + )) + + const learned = legacyTemplateDirection(tmpl.debit_account, tmpl.credit_account) + if ((learned === 'expense' && !isExpense) || (learned === 'income' && isExpense)) { + return buildLegacyMismatchResult(tmpl, match, absAmount, isExpense) + } const vatLines: VatJournalLine[] = [] if (isExpense && tmpl.vat_treatment) { @@ -327,13 +419,71 @@ export function buildMappingResultFromCounterpartyTemplate( credit_account: tmpl.credit_account, risk_level: 'NONE', confidence: match.confidence, - requires_review: false, + requires_review: isStaleReduced12Match( + tmpl.vat_treatment === 'reduced_12', transaction.date, tmpl.last_seen_date + ), default_private: isPrivate, vat_lines: vatLines, description: `Motpart: ${tmpl.counterparty_name} (${tmpl.occurrence_count} ggr)`, } } +/** + * Mirrored result for a sign-mismatched legacy template match (see + * buildMappingResultFromCounterpartyTemplate). Settlement and business + * accounts swap sides; a refund of an expense also mirrors the VAT legs so + * the moms follows the correction: deductible input VAT flips to a 2641 + * credit, and a reverse-charge credit note flips both fiktiv legs (credit + * 2645 / debit 2614) so Ruta 30/48 net back to zero. Income-learned + * mismatches book gross; the entry is review-gated either way. + */ +function buildLegacyMismatchResult( + tmpl: CategorizationTemplate, + match: CounterpartyTemplateMatch, + absAmount: number, + isExpense: boolean +): MappingResult { + const vatLines: VatJournalLine[] = [] + if (!isExpense && tmpl.vat_treatment) { + if (tmpl.vat_treatment === 'reverse_charge') { + for (const rcl of generateReverseChargeLines(absAmount)) { + vatLines.push({ + account_number: rcl.account_number, + debit_amount: rcl.credit_amount, + credit_amount: rcl.debit_amount, + description: rcl.line_description || '', + }) + } + } else { + const vatRate = getVatRate(tmpl.vat_treatment as VatTreatment) + if (vatRate > 0) { + const vatLine = generateInputVatLine(absAmount, vatRate) + if (vatLine) { + vatLines.push({ + account_number: vatLine.account_number, + debit_amount: 0, + credit_amount: vatLine.debit_amount, + description: vatLine.line_description || '', + }) + } + } + } + } + + return { + rule: null, + debit_account: tmpl.credit_account, + credit_account: tmpl.debit_account, + risk_level: 'NONE', + confidence: match.confidence, + requires_review: true, + direction_mismatch: true, + default_private: false, + vat_lines: vatLines, + description: `Motpart: ${tmpl.counterparty_name} (retur/återbetalning)`, + } +} + /** * Build a MappingResult from a multi-line counterparty template pattern. * @@ -344,10 +494,19 @@ export function buildMappingResultFromCounterpartyTemplate( function buildMultiLineMappingResult( tmpl: CategorizationTemplate, match: CounterpartyTemplateMatch, - transaction: Transaction + transaction: Transaction, + mirror: boolean = false ): MappingResult { const pattern = tmpl.line_pattern! - const absAmount = Math.abs(transaction.amount) + // Journal lines are always booked in SEK (see legacy path). + const absAmount = Math.abs(resolveSekAmount( + transaction.amount, transaction.amount_sek, transaction.currency, transaction.exchange_rate + )) + + // Sign mismatch (refund/repayment): flip every learned side so the mirrored + // entry reduces what the original pattern built up. + const side = (s: 'debit' | 'credit'): 'debit' | 'credit' => + mirror ? (s === 'debit' ? 'credit' : 'debit') : s const allLines: VatJournalLine[] = [] @@ -359,8 +518,8 @@ function buildMultiLineMappingResult( totalVat += vatAmount allLines.push({ account_number: entry.account, - debit_amount: entry.side === 'debit' ? vatAmount : 0, - credit_amount: entry.side === 'credit' ? vatAmount : 0, + debit_amount: side(entry.side) === 'debit' ? vatAmount : 0, + credit_amount: side(entry.side) === 'credit' ? vatAmount : 0, description: '', }) } @@ -377,8 +536,8 @@ function buildMultiLineMappingResult( nonVatAllocated += amount allLines.push({ account_number: entry.account, - debit_amount: entry.side === 'debit' ? amount : 0, - credit_amount: entry.side === 'credit' ? amount : 0, + debit_amount: side(entry.side) === 'debit' ? amount : 0, + credit_amount: side(entry.side) === 'credit' ? amount : 0, description: '', // Dimensions PR7: business lines carry the pattern's learned bag; // VAT/tax/rounding lines stay untagged. @@ -394,7 +553,7 @@ function buildMultiLineMappingResult( const roundingDiff = Math.round((absAmount - totalAllocated) * 100) / 100 if (roundingDiff !== 0) { // Determine the side for the rounding line (same side as business lines) - const businessSide = pattern.find(e => e.type === 'business')?.side ?? 'credit' + const businessSide = side(pattern.find(e => e.type === 'business')?.side ?? 'credit') allLines.push({ account_number: '3740', debit_amount: businessSide === 'debit' ? Math.abs(roundingDiff) : 0, @@ -405,15 +564,22 @@ function buildMultiLineMappingResult( return { rule: null, - debit_account: tmpl.debit_account, - credit_account: tmpl.credit_account, + debit_account: mirror ? tmpl.credit_account : tmpl.debit_account, + credit_account: mirror ? tmpl.debit_account : tmpl.credit_account, risk_level: 'NONE', confidence: match.confidence, - requires_review: false, + requires_review: mirror || isStaleReduced12Match( + pattern.some(e => e.type === 'vat' && e.vat_rate === 0.12), + transaction.date, + tmpl.last_seen_date + ), + ...(mirror ? { direction_mismatch: true } : {}), default_private: false, vat_lines: allLines, all_lines_complete: true, - description: `Motpart: ${tmpl.counterparty_name} (${tmpl.occurrence_count} ggr)`, + description: mirror + ? `Motpart: ${tmpl.counterparty_name} (retur/återbetalning)` + : `Motpart: ${tmpl.counterparty_name} (${tmpl.occurrence_count} ggr)`, } } @@ -437,20 +603,29 @@ export interface TemplateUpsertParams { /** * Low-level insert-or-update for a counterparty template. * - * - existingTemplate undefined → DB lookup by (userId, counterpartyName) + * - existingTemplate undefined → DB lookup by (companyId, counterpartyName) * - existingTemplate null → skip lookup (batch mode: caller knows none exists) * - existingTemplate object → use directly (batch mode: pre-fetched) * * Re-approval: accumulates occurrence_count, recalculates confidence from total. - * Correction: uses params.occurrenceCount/confidence, updates accounts. + * Correction: uses params.occurrenceCount/confidence, updates accounts. A + * "correction" whose settlement direction opposes the existing template's + * (a refund shape against an expense-learned template) is skipped: it is a + * different kind of event, not a correction, and must never flip the + * learned accounts. * Both paths use resolveSource() so lower-priority sources never overwrite higher. + * + * Returns true when a row was actually written. Write failures are logged + * (learning is non-critical, but it must never fail silently again: the + * post-refactor NOT NULL mismatch went unnoticed for months because these + * results were discarded). */ export async function insertOrUpdateTemplate( supabase: SupabaseClient, companyId: string, params: TemplateUpsertParams, existingTemplate?: CategorizationTemplate | null -): Promise { +): Promise { // Resolve existing template let existing: CategorizationTemplate | null = null if (existingTemplate === undefined) { @@ -465,6 +640,12 @@ export async function insertOrUpdateTemplate( existing = existingTemplate } + const logContext = { + companyId, + counterpartyName: params.counterpartyName, + source: params.source, + } + if (existing) { const isCorrection = existing.debit_account !== params.debitAccount || @@ -481,7 +662,23 @@ export async function insertOrUpdateTemplate( const newSource = resolveSource(existing.source, params.source) if (isCorrection) { - await supabase + // For multi-line templates the legacy fields can both be settlement-ish + // (direction 'unknown'); fall back to the pattern's business sides so + // the opposite-direction guard still holds. + let existingDirection = legacyTemplateDirection(existing.debit_account, existing.credit_account) + if (existingDirection === 'unknown' && existing.line_pattern && existing.line_pattern.length > 0) { + existingDirection = patternDirection(existing.line_pattern) + } + const incomingDirection = legacyTemplateDirection(params.debitAccount, params.creditAccount) + if ( + existingDirection !== 'unknown' && + incomingDirection !== 'unknown' && + existingDirection !== incomingDirection + ) { + return false + } + + const { error } = await supabase .from('categorization_templates') .update({ debit_account: params.debitAccount, @@ -497,12 +694,16 @@ export async function insertOrUpdateTemplate( line_pattern: params.linePattern !== undefined ? params.linePattern : existing.line_pattern, }) .eq('id', existing.id) + if (error) { + log.error('counterparty template correction failed', { ...logContext, error: error.message }) + return false + } } else { // Re-approval: accumulate count, recalculate confidence from total const newCount = existing.occurrence_count + params.occurrenceCount const newConfidence = calculateConfidence(newCount) - await supabase + const { error } = await supabase .from('categorization_templates') .update({ occurrence_count: newCount, @@ -514,9 +715,13 @@ export async function insertOrUpdateTemplate( ...(params.linePattern !== undefined ? { line_pattern: params.linePattern } : {}), }) .eq('id', existing.id) + if (error) { + log.error('counterparty template re-approval failed', { ...logContext, error: error.message }) + return false + } } } else { - await supabase + const { error } = await supabase .from('categorization_templates') .insert({ company_id: companyId, @@ -533,7 +738,13 @@ export async function insertOrUpdateTemplate( last_seen_date: params.lastSeenDate, source: params.source, }) + if (error) { + log.error('counterparty template insert failed', { ...logContext, error: error.message }) + return false + } } + + return true } /** @@ -547,6 +758,10 @@ export async function upsertCounterpartyTemplate( mappingResult: MappingResult, source: CategorizationTemplateSource ): Promise { + // Mirrored refund/repayment bookings must never be learned: they would + // flip the template's accounts and poison future matches. + if (mappingResult.direction_mismatch) return + const rawName = transaction.merchant_name || transaction.description if (!rawName) return @@ -652,6 +867,7 @@ interface VoucherLinePattern { entries: LinePatternEntry[] settlementAccount: string settlementSide: 'debit' | 'credit' + hasReverseCharge: boolean } /** @@ -665,6 +881,7 @@ function extractVoucherLinePattern( const settlement: PatternLine[] = [] const vat: PatternLine[] = [] const business: PatternLine[] = [] + let hasReverseCharge = false for (const line of lines) { if (isSettlementAccount(line.account)) { @@ -672,6 +889,12 @@ function extractVoucherLinePattern( } else if (isRoundingAccount(line.account)) { // Skip 3740 lines: rounding artifacts continue + } else if (REVERSE_CHARGE_VAT_ACCOUNTS.has(line.account)) { + // Fiktiv moms nets to zero inside the voucher: exclude it from the VAT + // total (it must not shrink the base the business ratios use) and only + // remember that the counterparty is reverse-charge. + hasReverseCharge = true + continue } else if (isVatAccount(line.account)) { vat.push(line) } else { @@ -699,10 +922,23 @@ function extractVoucherLinePattern( // VAT lines: store vat_rate, not ratio for (const v of vat) { - const treatment = VAT_ACCOUNT_TREATMENT[v.account] - if (!treatment) continue - const rate = vatTreatmentToRate(treatment) - if (rate === 0) continue + let rate: number + if (v.account === '2641' && vat.length === 1) { + // 2641 (debiterad ingående moms) is rate-agnostic in BAS: the account + // alone says nothing about 25/12/6%. Infer the rate from the voucher's + // own amounts and snap it to a legal rate. If nothing snaps, drop the + // VAT leg rather than learn a wrong rate. Only safe with a single VAT + // line: with several, each line's base is unknowable. + const observed = Math.abs(v.amount) / nonVatTotal + const snapped = LEGAL_VAT_RATES.find(r => Math.abs(observed - r) <= 0.015) + if (snapped === undefined) continue + rate = snapped + } else { + const treatment = VAT_ACCOUNT_TREATMENT[v.account] + if (!treatment) continue + rate = vatTreatmentToRate(treatment) + if (rate === 0) continue + } entries.push({ account: v.account, type: 'vat', @@ -731,6 +967,7 @@ function extractVoucherLinePattern( entries, settlementAccount: settlement[0].account, settlementSide, + hasReverseCharge, } } @@ -998,12 +1235,28 @@ export async function populateTemplatesFromSieVouchers( creditAccount = item.settlementAccount } - // VAT info from first VAT entry (for legacy fields) + // VAT info from first VAT entry (for legacy fields). The learned rate is + // authoritative: for 2641 it was inferred from the voucher amounts, so + // mapping it back through the account table would re-hardcode 25%. const firstVat = vatEntries[0] - const vatAccount = firstVat?.account ?? null - const vatTreatment = vatAccount ? (VAT_ACCOUNT_TREATMENT[vatAccount] ?? null) : null + let vatAccount = firstVat?.account ?? null + let vatTreatment = firstVat?.vat_rate !== undefined + ? rateToTreatment(firstVat.vat_rate) + : (vatAccount ? (VAT_ACCOUNT_TREATMENT[vatAccount] ?? null) : null) - await insertOrUpdateTemplate(supabase, companyId, { + // Reverse-charge counterparty (fiktiv moms in every source voucher, no + // deductible VAT): the simple builder regenerates the RC legs from the + // treatment, so record it on the legacy fields. + if ( + isSimple && + !vatAccount && + item.pattern.voucherPatterns.every(vp => vp.hasReverseCharge) + ) { + vatTreatment = 'reverse_charge' + vatAccount = '2645' + } + + const written = await insertOrUpdateTemplate(supabase, companyId, { counterpartyName: item.normalizedName, aliases: item.aliases, debitAccount, @@ -1018,7 +1271,7 @@ export async function populateTemplatesFromSieVouchers( linePattern: isSimple ? null : avgPattern, }, existing) - count += 1 + if (written) count += 1 } return count diff --git a/lib/bookkeeping/transaction-entries.ts b/lib/bookkeeping/transaction-entries.ts index 57bea4fe..9d179df7 100644 --- a/lib/bookkeeping/transaction-entries.ts +++ b/lib/bookkeeping/transaction-entries.ts @@ -2,6 +2,7 @@ import { createJournalEntry, findFiscalPeriod } from './engine' import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils' import { coerceDimensionsBag } from './dimension-resolver' import { extractNetAmount, extractVatAmount } from './vat-entries' +import { roundOre } from '@/lib/money' import { InvalidMappingResultError } from '@/lib/bookkeeping/errors' import { createLogger } from '@/lib/logger' import type { SupabaseClient } from '@supabase/supabase-js' @@ -190,10 +191,14 @@ export function buildTransactionEntryLines( const creditAccount = mappingResult.credit_account if (mappingResult.vat_lines.length > 0) { - // Has output VAT - const vatCredit = mappingResult.vat_lines - .filter(l => l.credit_amount > 0) - .reduce((sum, l) => sum + l.credit_amount, 0) + // Has output VAT. Net the credits against any debit VAT legs: a + // mirrored reverse-charge refund carries a credit 2645 + debit 2614 + // pair that nets to zero, so the business line keeps the gross amount. + // Ordinary output-VAT lines are credit-only (debit_amount 0), so the + // net equals the old credit-sum for every non-RC path. + const vatCredit = roundOre( + mappingResult.vat_lines.reduce((sum, l) => sum + l.credit_amount - l.debit_amount, 0) + ) const netAmount = Math.round((absAmount - vatCredit) * 100) / 100 // Debit bank for gross amount diff --git a/lib/transactions/categorize-core.ts b/lib/transactions/categorize-core.ts index 5e7bcb61..3cd4c6ba 100644 --- a/lib/transactions/categorize-core.ts +++ b/lib/transactions/categorize-core.ts @@ -337,7 +337,7 @@ export async function categorizeMatchedTransaction( try { await upsertCounterpartyTemplate( - supabase, userId, transaction as Transaction, mappingResult, 'user_approved' + supabase, companyId, transaction as Transaction, mappingResult, 'user_approved' ) } catch { /* non-critical */ } diff --git a/supabase/migrations/20260711100000_categorization_templates_learning_fix.sql b/supabase/migrations/20260711100000_categorization_templates_learning_fix.sql new file mode 100644 index 00000000..0191f8f7 --- /dev/null +++ b/supabase/migrations/20260711100000_categorization_templates_learning_fix.sql @@ -0,0 +1,25 @@ +-- Migration: make categorization_templates writable again (counterparty +-- template learning has been dead since the multi-tenant refactor). +-- +-- 20260330130000 re-scoped this table from user_id to company_id and the +-- insert path (lib/bookkeeping/counterparty-templates.ts) stopped writing +-- user_id, but the column kept its NOT NULL: the refactor relaxed user_id on +-- audit_log and company_settings but not here. Every template INSERT since +-- 2026-03-30 failed with a null violation that supabase-js returns rather +-- than throws, so nothing was logged and the learning loop silently died +-- (prod evidence: 750 SIE imports since the refactor, zero new templates). +ALTER TABLE public.categorization_templates + ALTER COLUMN user_id DROP NOT NULL; + +-- The user_id indexes serve no query anymore: every read filters company_id, +-- and UNIQUE (company_id, counterparty_name) already provides the +-- company-scoped lookup path. Replace the stale active-lookup index with a +-- company-scoped one (matches the .eq(company_id).eq(is_active) reads). +DROP INDEX IF EXISTS public.idx_categorization_templates_user_id; +DROP INDEX IF EXISTS public.idx_categorization_templates_counterparty; +DROP INDEX IF EXISTS public.idx_categorization_templates_active; +CREATE INDEX IF NOT EXISTS idx_categorization_templates_company_active + ON public.categorization_templates (company_id, is_active) + WHERE is_active = true; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/categorization-templates-learning.pg.test.ts b/tests/pg/categorization-templates-learning.pg.test.ts new file mode 100644 index 00000000..f1d87a2b --- /dev/null +++ b/tests/pg/categorization-templates-learning.pg.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { randomUUID } from 'crypto' +import { getPool } from '@/tests/pg/setup' +import { seedCompany } from '@/tests/pg/fixtures' + +/** + * Covers 20260711100000_categorization_templates_learning_fix: + * + * The multi-tenant refactor (20260330130000) re-scoped this table to + * company_id and the insert path (lib/bookkeeping/counterparty-templates.ts + * insertOrUpdateTemplate) stopped writing user_id, but the column kept its + * NOT NULL. Every template insert failed silently for months; the unit tests + * stayed green because they mock Supabase. This test locks the real schema + * contract: the EXACT column set the lib writes must insert cleanly. + */ + +const LIB_INSERT_COLUMNS = ` + company_id, counterparty_name, counterparty_aliases, + debit_account, credit_account, vat_treatment, vat_account, + category, line_pattern, occurrence_count, confidence, + last_seen_date, source` + +async function insertTemplate(companyId: string, counterpartyName: string) { + return getPool().query( + `INSERT INTO public.categorization_templates (${LIB_INSERT_COLUMNS}) + VALUES ($1, $2, ARRAY['telia sverige ab'], '6200', '1930', + 'standard_25', '2641', NULL, NULL, 1, 0.45, '2026-06-15', 'user_approved') + RETURNING id, company_id, user_id, is_active`, + [companyId, counterpartyName], + ) +} + +describe('categorization_templates: learning write contract', () => { + it('accepts the exact column set the lib writes (no user_id)', async () => { + const { companyId } = await seedCompany() + + const { rows } = await insertTemplate(companyId, `telia-${randomUUID()}`) + + expect(rows).toHaveLength(1) + expect(rows[0].company_id).toBe(companyId) + expect(rows[0].user_id).toBeNull() + expect(rows[0].is_active).toBe(true) + }) + + it('enforces one template per (company_id, counterparty_name)', async () => { + const { companyId } = await seedCompany() + const name = `telia-${randomUUID()}` + + await insertTemplate(companyId, name) + await expect(insertTemplate(companyId, name)).rejects.toMatchObject({ + code: '23505', + }) + }) + + it('allows the same counterparty_name in different companies', async () => { + const { companyId: companyA } = await seedCompany() + const { companyId: companyB } = await seedCompany() + const name = `telia-${randomUUID()}` + + await insertTemplate(companyA, name) + const { rows } = await insertTemplate(companyB, name) + expect(rows).toHaveLength(1) + }) + + it('still rejects a company_id that is not a real company (FK intact)', async () => { + await expect(insertTemplate(randomUUID(), `ghost-${randomUUID()}`)).rejects.toMatchObject({ + code: '23503', + }) + }) +}) diff --git a/types/index.ts b/types/index.ts index 088229e0..5ca3a275 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1595,6 +1595,11 @@ export interface MappingResult { vat_lines: VatJournalLine[] all_lines_complete?: boolean // when true, vat_lines contains ALL non-settlement lines description: string + // Set when a matched counterparty template's learned direction contradicts + // the transaction sign (e.g. an incoming refund matching an expense-learned + // template). The result is mirrored and review-gated, and must never be + // learned back into the template (it would flip the learned accounts). + direction_mismatch?: boolean // Dimensions bag applied to the business (expense/revenue) lines of the // generated entry: from a counterparty template's line pattern or an // explicit categorize param (dimensions PR7). Bank/VAT lines stay untagged. @@ -1631,7 +1636,9 @@ export interface LinePatternEntry { // Per-tenant counterparty-based categorization template export interface CategorizationTemplate { id: string - user_id: string + // Pre-multi-tenant relic: nullable since 20260711100000 and never written + // by the learning path anymore. Scoping is company_id. + user_id: string | null company_id: string counterparty_name: string counterparty_aliases: string[]