Files
accounted/tests/pg/categorization-templates-learning.pg.test.ts
T
Jakob Wennberg 650c7be5e1 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>
2026-07-11 23:13:50 +02:00

71 lines
2.6 KiB
TypeScript

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',
})
})
})