5291806c37
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>
160 lines
7.8 KiB
TypeScript
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([])
|
|
})
|
|
})
|