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:
co-authored by
Claude Fable 5
parent
3a2c57a167
commit
650c7be5e1
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user