Files
accounted/tests/pg/observed-parties-rpc.pg.test.ts
Jakob Wennberg 5291806c37 feat(parties): observed parties from voucher text, ledger_key and its mirror (#2168)
Migrants arrive with vouchers, not bank transactions, so the bank-keyed
ledger context is empty for them. This adds the description-keyed twin.

- public.ledger_key(text): legibility key on top of the frozen
  normalize_counterparty_key mirror: strips AP-register prefixes (levfakt,
  leverantörsfaktura från N, levbet, faktura, kvitto, utgift), the supplier
  number that follows them, and trailing 1-3 digit runs, never "inköp".
  Mirrored by lib/parties/ledger-key.ts; the pair is pinned by a shared
  fixture list in the pg test.
- public.get_observed_parties(company, from_date, limit): posted vouchers
  grouped by ledger_key(description) with occurrences, variants, expense
  and revenue SEK from the lines, first/last seen, median cadence and the
  Laplace-smoothed dominant result account. Excludes storno, opening
  balance, year-end and VAT settlement, and vouchers that carry a bank
  merchant name (those stay with get_ledger_deep_context). SECURITY
  INVOKER, so RLS scopes it. Never stored.
- lib/parties/classify.ts: the deterministic pre-classifier moved out of
  the evaluation script so product and evaluation share one implementation
  (0.965 agreement with the founder labels, party recall 0.99).
- lib/parties/observed.ts: RPC wrapper that classifies each row and
  derives a display rhythm from the cadence.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:33:30 +02:00

160 lines
7.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from './setup'
import { insertPostedJournalEntry, insertTransaction, seedCompany } from './fixtures'
import { ledgerKey } from '@/lib/parties/ledger-key'
import { LEDGER_KEY_CASES } from '@/lib/parties/__tests__/ledger-key.test'
/**
* Parties, phase 1b (migration 20260902170000): ledger_key() and the
* observed-parties RPC keyed on voucher descriptions.
*
* The TS/SQL parity block runs the shared fixture list through both
* implementations; a change to either side without the other fails here.
*/
describe('ledger_key parity (pg)', () => {
it('mirrors lib/parties/ledger-key.ts on the shared fixtures', async () => {
for (const [raw, expected] of LEDGER_KEY_CASES) {
const { rows } = await getPool().query<{ k: string }>(`SELECT public.ledger_key($1) AS k`, [raw])
expect(rows[0]!.k, raw).toBe(ledgerKey(raw))
expect(rows[0]!.k, raw).toBe(expected)
}
})
it('treats NULL as an empty key', async () => {
const { rows } = await getPool().query<{ k: string }>(`SELECT public.ledger_key(NULL) AS k`)
expect(rows[0]!.k).toBe('')
})
})
interface Observed {
key: string
name: string
occurrences: number
variant_count: number
expense_sek: number
revenue_sek: number
cadence_days: number | null
dominant_account_number: string | null
dominant_account_share: number | null
}
async function observed(companyId: string, userId: string, fromDate: string | null = null): Promise<Observed[]> {
return withUserContext(userId, async (client) => {
const { rows } = await client.query<{ r: Observed[] }>(
`SELECT public.get_observed_parties($1, $2, 200) AS r`,
[companyId, fromDate],
)
return rows[0]!.r
})
}
const expense = (account: string, amount: number) => [
{ accountNumber: account, debitAmount: amount, creditAmount: 0 },
{ accountNumber: '2440', debitAmount: 0, creditAmount: amount },
]
describe('get_observed_parties (pg)', () => {
it('groups posted vouchers by ledger_key, sums expense SEK, and reports cadence and dominant account', async () => {
const c = await seedCompany()
const base = { userId: c.userId, companyId: c.companyId, fiscalPeriodId: c.fiscalPeriodId, sourceType: 'import' }
await insertPostedJournalEntry({ ...base, entryDate: '2026-01-10', description: 'Levfakt BEIJER BYGGMATERIAL AB (2089)', lines: expense('4000', 1000) })
await insertPostedJournalEntry({ ...base, entryDate: '2026-02-09', description: 'Levfakt Beijer Byggmaterial AB, 097 (1001)', lines: expense('4000', 2000) })
await insertPostedJournalEntry({ ...base, entryDate: '2026-03-11', description: 'Levfakt Beijer Byggmaterial AB (2089)', lines: expense('4010', 500) })
await insertPostedJournalEntry({ ...base, entryDate: '2026-03-15', description: 'Inköp av varor', lines: expense('4010', 300) })
const rows = await observed(c.companyId, c.userId)
const beijer = rows.find((r) => r.key === 'beijer byggmaterial')
expect(beijer).toBeDefined()
expect(beijer!.occurrences).toBe(3)
expect(beijer!.variant_count).toBe(3)
expect(Number(beijer!.expense_sek)).toBe(3500)
expect(Number(beijer!.revenue_sek)).toBe(0)
expect(beijer!.cadence_days).toBe(30)
expect(beijer!.dominant_account_number).toBe('4000')
// Laplace: (2+1)/(3+2)
expect(Number(beijer!.dominant_account_share)).toBe(0.6)
expect(rows.find((r) => r.key === 'inköp av varor')).toBeDefined()
// Sorted by money, so Beijer comes first.
expect(rows[0]!.key).toBe('beijer byggmaterial')
})
it('counts revenue on 3xxx credits and skips pure balance-sheet vouchers', async () => {
const c = await seedCompany()
const base = { userId: c.userId, companyId: c.companyId, fiscalPeriodId: c.fiscalPeriodId, sourceType: 'import' }
await insertPostedJournalEntry({
...base,
entryDate: '2026-04-01',
description: 'Kundfaktura Acme Konsult AB',
lines: [
{ accountNumber: '1510', debitAmount: 12500, creditAmount: 0 },
{ accountNumber: '3011', debitAmount: 0, creditAmount: 10000 },
{ accountNumber: '2611', debitAmount: 0, creditAmount: 2500 },
],
})
await insertPostedJournalEntry({
...base,
entryDate: '2026-04-02',
description: 'Överföring till sparkonto',
lines: [
{ accountNumber: '1940', debitAmount: 5000, creditAmount: 0 },
{ accountNumber: '1930', debitAmount: 0, creditAmount: 5000 },
],
})
const rows = await observed(c.companyId, c.userId)
expect(rows).toHaveLength(1)
expect(rows[0]!.key).toBe('kundfaktura acme konsult')
expect(Number(rows[0]!.revenue_sek)).toBe(10000)
})
it('excludes storno, opening-balance, year-end and VAT-settlement vouchers, and honours the window', async () => {
const c = await seedCompany()
const base = { userId: c.userId, companyId: c.companyId, fiscalPeriodId: c.fiscalPeriodId }
await insertPostedJournalEntry({ ...base, sourceType: 'import', entryDate: '2025-06-01', description: 'Telia Sverige AB', lines: expense('6212', 100) })
await insertPostedJournalEntry({ ...base, sourceType: 'import', entryDate: '2026-06-01', description: 'Telia Sverige AB', lines: expense('6212', 100) })
await insertPostedJournalEntry({ ...base, sourceType: 'storno', entryDate: '2026-06-02', description: 'Telia Sverige AB', lines: expense('6212', 100) })
await insertPostedJournalEntry({ ...base, sourceType: 'opening_balance', entryDate: '2026-01-01', description: 'Ingående balans', lines: expense('4000', 100) })
await insertPostedJournalEntry({ ...base, sourceType: 'year_end', entryDate: '2026-12-31', description: 'Årets resultat', lines: expense('8999', 100) })
const all = await observed(c.companyId, c.userId)
const telia = all.find((r) => r.key === 'telia sverige')
expect(telia!.occurrences).toBe(2)
expect(all.find((r) => r.key === 'ingående balans')).toBeUndefined()
expect(all.find((r) => r.key === 'årets resultat')).toBeUndefined()
const windowed = await observed(c.companyId, c.userId, '2026-01-01')
expect(windowed.find((r) => r.key === 'telia sverige')!.occurrences).toBe(1)
})
it('leaves vouchers that carry a bank merchant name to the bank-keyed RPC', async () => {
const c = await seedCompany()
const base = { userId: c.userId, companyId: c.companyId, fiscalPeriodId: c.fiscalPeriodId, sourceType: 'bank_transaction' }
const withBank = await insertPostedJournalEntry({ ...base, entryDate: '2026-05-01', description: 'Loopia AB', lines: expense('6542', 99) })
await insertPostedJournalEntry({ ...base, entryDate: '2026-05-02', description: 'Loopia AB', lines: expense('6542', 99) })
const txId = await insertTransaction({
userId: c.userId,
companyId: c.companyId,
journalEntryId: withBank,
description: 'LOOPIA AB',
amount: -99,
date: '2026-05-01',
})
await getPool().query(`UPDATE public.transactions SET merchant_name = 'LOOPIA AB' WHERE id = $1`, [txId])
const rows = await observed(c.companyId, c.userId)
const loopia = rows.find((r) => r.key === 'loopia')
expect(loopia!.occurrences).toBe(1)
})
it('does not leak across companies and returns [] for an empty company', async () => {
const a = await seedCompany()
const b = await seedCompany()
await insertPostedJournalEntry({ userId: a.userId, companyId: a.companyId, fiscalPeriodId: a.fiscalPeriodId, sourceType: 'import', description: 'Levfakt Dahls Bageri AB (167)', lines: expense('4000', 100) })
expect(await observed(b.companyId, b.userId)).toEqual([])
// A member of company b asking for company a's rows sees nothing: RLS.
const cross = await withUserContext(b.userId, async (client) => {
const { rows } = await client.query<{ r: Observed[] }>(`SELECT public.get_observed_parties($1, NULL, 200) AS r`, [a.companyId])
return rows[0]!.r
})
expect(cross).toEqual([])
})
})