650c7be5e1
* 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>
71 lines
2.6 KiB
TypeScript
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',
|
|
})
|
|
})
|
|
})
|