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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(types): CategorizationTemplate.user_id is nullable since 20260711100000 (CodeRabbit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): use roundOre for the VAT netting, keep the ore-round ratchet at baseline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-11 23:13:50 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 3a2c57a167
commit 650c7be5e1
11 changed files with 950 additions and 43 deletions
+3
View File
@@ -63,3 +63,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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)
@@ -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',
+1 -1
View File
@@ -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
@@ -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<string, unknown[]> = {}
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<string, unknown[]>) {
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)
})
})
})
+284 -31
View File
@@ -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<string, string> = {
'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<void> {
): Promise<boolean> {
// 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<void> {
// 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
+9 -4
View File
@@ -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
+1 -1
View File
@@ -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 */ }
@@ -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';
@@ -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',
})
})
})
+8 -1
View File
@@ -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[]