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>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyKey } from '../classify'
|
||||
|
||||
// Founder-labelled examples from the 2026-09-02 golden set, one per rule.
|
||||
describe('classifyKey', () => {
|
||||
it.each([
|
||||
['levfakt beijer byggmaterial 097', '4000', 'party'],
|
||||
['leverantörsfaktura från 157 råå bryggeri', '4010', 'party'],
|
||||
['loopia', '6542', 'party'],
|
||||
['taxi stockholm', '5800', 'party'],
|
||||
['inköp av varor', '4010', 'category'],
|
||||
['bankkostnad', '6570', 'bank'],
|
||||
['baspaket bank', '6570', 'bank'],
|
||||
['fika', '7690', 'category'],
|
||||
['löneutbetalning anställd 15', '7210', 'payroll'],
|
||||
['transaktion betalning mot rapport', '7200', 'payroll'],
|
||||
['periodisering av verifikation d19', '5010', 'adjustment'],
|
||||
['lagerförändring', '4000', 'adjustment'],
|
||||
['bolagsverket ändra bolagsordning', '6991', 'authority'],
|
||||
['skattekonto', '6992', 'authority'],
|
||||
['klarna', '6570', 'intermediary'],
|
||||
['diesel', '5611', 'category'],
|
||||
['telefon', '6210', 'category'],
|
||||
] as const)('%s (%s) -> %s', (key, acct, expected) => {
|
||||
expect(classifyKey({ key, acct })).toBe(expected)
|
||||
})
|
||||
|
||||
it('treats a card-platform line with a person suffix as a party', () => {
|
||||
expect(classifyKey({ key: 'pleo andreas', acct: '4535' })).toBe('party')
|
||||
})
|
||||
|
||||
it('does not let BAS description examples make Google look generic', () => {
|
||||
expect(classifyKey({ key: 'cc google co', acct: '5420' })).toBe('party')
|
||||
})
|
||||
|
||||
it('returns unsure for an empty or all-noise key', () => {
|
||||
expect(classifyKey({ key: '' })).toBe('unsure')
|
||||
expect(classifyKey({ key: '12 34' })).toBe('unsure')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ledgerKey } from '../ledger-key'
|
||||
|
||||
// Fixture pairs are shared with tests/pg/observed-parties-rpc.pg.test.ts,
|
||||
// which runs the same inputs through public.ledger_key() and asserts parity.
|
||||
export const LEDGER_KEY_CASES: [string, string][] = [
|
||||
['Levfakt BEIJER BYGGMATERIAL AB (2089)', 'beijer byggmaterial'],
|
||||
['Levfakt Beijer Byggmaterial AB, 097 (1001)', 'beijer byggmaterial'],
|
||||
['Leverantörsfaktura från 18 Loopia, 1009146000', 'loopia'],
|
||||
['Levfakt Varsego Sverige AB (178)', 'varsego sverige'],
|
||||
['Levfkt 1555 Telge Energi', 'telge energi'],
|
||||
['Levbet. MiSUMi (2189107)', 'misumi'],
|
||||
['Kvitto OpenAI', 'openai'],
|
||||
['Inköp av varor', 'inköp av varor'],
|
||||
['Bankkostnad', 'bankkostnad'],
|
||||
['Google Workspace - 2025-09', 'google workspace'],
|
||||
['Telia Sverige AB', 'telia sverige'],
|
||||
['', ''],
|
||||
]
|
||||
|
||||
describe('ledgerKey', () => {
|
||||
it.each(LEDGER_KEY_CASES)('%s -> %s', (raw, expected) => {
|
||||
expect(ledgerKey(raw)).toBe(expected)
|
||||
})
|
||||
|
||||
it('never strips "inköp", which would turn a category into a vendor', () => {
|
||||
expect(ledgerKey('Inköp varor material')).toBe('inköp varor material')
|
||||
})
|
||||
|
||||
it('falls back to the normalised key when stripping would leave nothing', () => {
|
||||
expect(ledgerKey('Faktura 12')).not.toBe('')
|
||||
})
|
||||
|
||||
it('merges the two AP spellings of one supplier onto one key', () => {
|
||||
expect(ledgerKey('Levfakt BEIJER BYGGMATERIAL AB (2089)')).toBe(ledgerKey('Levfakt Beijer Byggmaterial AB, 097 (1001)'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getObservedParties, rhythmFromCadence } from '../observed'
|
||||
|
||||
function clientWith(result: { data: unknown; error: { message: string } | null }): SupabaseClient {
|
||||
return { rpc: vi.fn().mockResolvedValue(result) } as unknown as SupabaseClient
|
||||
}
|
||||
|
||||
const row = (over: Partial<Record<string, unknown>>) => ({
|
||||
key: 'beijer byggmaterial',
|
||||
name: 'Levfakt Beijer Byggmaterial AB (2089)',
|
||||
variants: ['Levfakt Beijer Byggmaterial AB (2089)'],
|
||||
variant_count: 1,
|
||||
occurrences: 12,
|
||||
expense_sek: 48000,
|
||||
revenue_sek: 0,
|
||||
first_seen: '2026-01-05',
|
||||
last_seen: '2026-06-09',
|
||||
cadence_days: 14,
|
||||
dominant_account_number: '4000',
|
||||
dominant_account_share: 0.9,
|
||||
dominant_account_count: 11,
|
||||
dominant_account_total: 12,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('getObservedParties', () => {
|
||||
it('calls the RPC with company, window and limit, and classifies each row', async () => {
|
||||
const client = clientWith({
|
||||
data: [row({}), row({ key: 'inköp av varor', name: 'Inköp av varor', dominant_account_number: '4010' })],
|
||||
error: null,
|
||||
})
|
||||
const out = await getObservedParties(client, 'co-1', { fromDate: '2025-09-01', limit: 50 })
|
||||
expect(client.rpc).toHaveBeenCalledWith('get_observed_parties', {
|
||||
p_company_id: 'co-1',
|
||||
p_from_date: '2025-09-01',
|
||||
p_limit: 50,
|
||||
})
|
||||
expect(out.map((p) => [p.key, p.label, p.rhythm])).toEqual([
|
||||
['beijer byggmaterial', 'party', 'irregular'],
|
||||
['inköp av varor', 'category', 'irregular'],
|
||||
])
|
||||
})
|
||||
|
||||
it('filters to the requested labels', async () => {
|
||||
const client = clientWith({
|
||||
data: [row({}), row({ key: 'löneutbetalning anställd 3', name: 'Löneutbetalning', dominant_account_number: '7210' })],
|
||||
error: null,
|
||||
})
|
||||
const out = await getObservedParties(client, 'co-1', { labels: ['party'] })
|
||||
expect(out).toHaveLength(1)
|
||||
expect(out[0]!.key).toBe('beijer byggmaterial')
|
||||
})
|
||||
|
||||
it('surfaces RPC errors and tolerates a null payload', async () => {
|
||||
await expect(getObservedParties(clientWith({ data: null, error: { message: 'boom' } }), 'co-1')).rejects.toThrow(
|
||||
'get_observed_parties failed: boom',
|
||||
)
|
||||
expect(await getObservedParties(clientWith({ data: null, error: null }), 'co-1')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rhythmFromCadence', () => {
|
||||
it.each([
|
||||
[null, null],
|
||||
[7, 'weekly'],
|
||||
[30, 'monthly'],
|
||||
[91, 'quarterly'],
|
||||
[365, 'yearly'],
|
||||
[2, 'irregular'],
|
||||
[180, 'irregular'],
|
||||
] as const)('%s -> %s', (days, expected) => {
|
||||
expect(rhythmFromCadence(days)).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
/**
|
||||
* Pre-classifier for counterparty keys: routes a key before entity
|
||||
* resolution. Deterministic rules only; scored against the founder-labelled
|
||||
* golden set on 2026-09-02 at 0.965 agreement (party recall 0.99, non-party
|
||||
* recall 0.93) on 180 held-out keys, see scripts/parties/README.md.
|
||||
*
|
||||
* Label vocabulary (settled with the founder 2026-09-02):
|
||||
* - party: a real counterpart. AP prefixes always mean party. A marketplace or
|
||||
* processor the company actually pays is a party; a card-platform line
|
||||
* with an employee's name is a party; insurance premiums are parties.
|
||||
* - category: an expense description with no counterpart in it.
|
||||
* - payroll: salary, benefits, expense claims to a person.
|
||||
* - adjustment: periodisering, omföring, lagerförändring, nedskrivning, rättelse.
|
||||
* - authority: a state fee or tax where the counterpart is fixed.
|
||||
* - bank: bank fees and bank products.
|
||||
* - intermediary: a rail carrying someone else's money (Klarna, Swish, Zettle payouts).
|
||||
* - unsure: nothing to decide on.
|
||||
*/
|
||||
export const PARTY_LABELS = ['party', 'category', 'payroll', 'adjustment', 'authority', 'bank', 'intermediary', 'unsure'] as const
|
||||
export type PartyLabel = (typeof PARTY_LABELS)[number]
|
||||
|
||||
const STOP = new Set([
|
||||
'av', 'och', 'för', 'via', 'kort', 'ej', 'moms', 'inkl', 'exkl', 'per', 'utanför', 'inom', 'eu', 'se', 'ab',
|
||||
'till', 'mot', 'med', 'från', 'på', 'i', 'en', 'ett', 'den', 'det', 'som', 'om', 'utan', 'the', 'usd', 'eur', 'sek',
|
||||
])
|
||||
|
||||
// Generic words voucher text uses for a category without a counterpart.
|
||||
// Geographic tokens are deliberately absent: "taxi stockholm" reads as a party
|
||||
// to the founder, "taxiresor och parkering" does not.
|
||||
const GENERIC = [
|
||||
'inköp', 'inkp', 'kvitto', 'kvitton', 'fika', 'diesel', 'bensin', 'bränsle', 'försäkring', 'telefon', 'mobil', 'hyra',
|
||||
'lokalhyra', 'frakt', 'hosting', 'julklapp', 'frimärken', 'utlägg', 'hotell', 'resa', 'resor', 'resekostnader',
|
||||
'biljett', 'biljetter', 'biljettkostnad', 'taxi', 'taxiresor', 'parkering', 'parkeringsavgifter', 'representation',
|
||||
'måltidsrepresentation', 'kollektivtrafik', 'kollektivtra', 'kontorsmaterial', 'förbrukning', 'förbrukningsmateriel',
|
||||
'frbrukningsmateriel', 'programvara', 'mjukvara', 'licens', 'avgift', 'avgifter', 'traktamente', 'traktamenten',
|
||||
'bilersättning', 'material', 'varor', 'tjänster', 'tjnster', 'faktura', 'kostnad', 'kostnader', 'betalning', 'utgift',
|
||||
'företagskvitto', 'fretagskvitto', 'fretagskvitton', 'övriga', 'personbilskostnader', 'glykol', 'lastbil', 'verktyg',
|
||||
'abonnemang', 'subscription', 'ittjänster', 'itprodukter', 'inrikes', 'utrikes', 'utlandsk', 'utländsk', 'europeisk',
|
||||
'annonsering', 'konsultarvoden', 'momspliktig', 'momsfri', 'skattefritt', 'utomlands', 'internet', 'överföring',
|
||||
'kortköputtag', 'kortkp', 'uttag', 'avdragsgill', 'avdragbar', 'schablon', 'person', 'deltagare', 'syfte', 'möte',
|
||||
'samarbete', 'rapporterad', 'kundfaktura', 'påminnelseavgifter', 'avräkningsnota', 'avrkningsnota', 'fakturaservice',
|
||||
'påminnelse', 'ränta', 'dröjsmålsränta', 'porto', 'kontor', 'lokal', 'el', 'vatten', 'värme', 'städning', 'reparation',
|
||||
'underhåll', 'service', 'utbildning', 'kurs', 'litteratur', 'tidningar', 'bok', 'böcker', 'gåva', 'gåvor', 'mat',
|
||||
'lunch', 'middag', 'kaffe', 'personal', 'friskvård', 'sjukvård', 'arbetskläder', 'skyddskläder',
|
||||
]
|
||||
|
||||
// BAS account NAMES only. Descriptions name example vendors (Google,
|
||||
// Facebook) and would make real parties look generic, the trap the July
|
||||
// design found.
|
||||
let vocabCache: Set<string> | null = null
|
||||
function vocab(): Set<string> {
|
||||
if (vocabCache) return vocabCache
|
||||
const v = new Set<string>(GENERIC)
|
||||
for (const a of BAS_REFERENCE) {
|
||||
if (a.account_class < 4) continue
|
||||
for (const t of a.account_name.toLowerCase().split(/[^a-zåäöé]+/)) if (t.length >= 3) v.add(t)
|
||||
}
|
||||
vocabCache = v
|
||||
return v
|
||||
}
|
||||
|
||||
const AP_PREFIX = /^(levfakt|levfkt|lev\.?fakt\.?|leverantörsfaktura|leverantorsfaktura|levbet\.?|lev\.?bet\.?)\b/
|
||||
const PAYROLL = /\b(lön|löner|löne\w*|lneutbetalning|lönebesked|salary|semesterskuld|arbetsgivaravgift\w*)\b/
|
||||
const ADJUSTMENT =
|
||||
/(periodisering|omföring|omforing|lagerförändring|lagerforandring|nedskrivning|rättelse|rattelse|kostnadsföring|avskrivning|bokslut|kursdiff|valutakurs|eur till sek|omvänd betalningsskyldighet)/
|
||||
const BANK = /(bankkostnad|bankavgift|banktjänst|baspaket bank|bank årsavg|årsavg|avi överdrag|företagspaket)/
|
||||
const AUTHORITY = /\b(skatteverket|bolagsverket|transportstyrelsen|försäkringskassan|kronofogden|tullverket|skattekonto)\b/
|
||||
const INTERMEDIARY = /\b(klarna|paypal|zettle|izettle|swish|payex|bankgirot|adyen|nets)\b/
|
||||
|
||||
function acctNum(a: string | null | undefined): number {
|
||||
const n = Number(a)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a normalised counterparty key. `acct` is the dominant result
|
||||
* account of the key's vouchers, used only to catch payroll booked on
|
||||
* 70xx-72xx without a telling word in the text.
|
||||
*/
|
||||
export function classifyKey(input: { key: string; acct?: string | null }): PartyLabel {
|
||||
const k = input.key.toLowerCase().trim()
|
||||
if (!k) return 'unsure'
|
||||
const acct = acctNum(input.acct)
|
||||
if (AP_PREFIX.test(k)) return 'party'
|
||||
if (PAYROLL.test(k) || (acct >= 7010 && acct <= 7299)) return 'payroll'
|
||||
if (ADJUSTMENT.test(k)) return 'adjustment'
|
||||
if (BANK.test(k)) return 'bank'
|
||||
if (AUTHORITY.test(k)) return 'authority'
|
||||
if (INTERMEDIARY.test(k)) return 'intermediary'
|
||||
const content = k
|
||||
.split(/\s+/)
|
||||
.filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !/^k\d+$/.test(t) && !STOP.has(t))
|
||||
if (content.length === 0) return 'unsure'
|
||||
const v = vocab()
|
||||
return content.every((t) => v.has(t)) ? 'category' : 'party'
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { normalizeCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
|
||||
|
||||
/**
|
||||
* Legibility key for a voucher description: the identity string an observed
|
||||
* party is grouped and displayed by.
|
||||
*
|
||||
* Built on top of normalizeCounterpartyName() (mirrored in SQL by
|
||||
* normalize_counterparty_key) and adds the stages the AP registers of Fortnox,
|
||||
* Visma and BL make necessary: "Levfakt BEIJER BYGGMATERIAL AB (2089)" and
|
||||
* "Levfakt Beijer Byggmaterial AB, 097" must land on one key,
|
||||
* "Leverantörsfaktura från 18 Loopia" on "loopia".
|
||||
*
|
||||
* Mirrored in SQL by public.ledger_key() (migration 20260902170000) and pinned
|
||||
* by tests/pg/observed-parties-rpc.pg.test.ts. Change both or neither.
|
||||
*
|
||||
* "inköp" is deliberately not a stripped prefix: it turns the generic
|
||||
* "inköp av varor" into a vendor-looking "varor" (measured 2026-07-27).
|
||||
*/
|
||||
const AP_PREFIX = /^(levfakt|levfkt|leverantörsfaktura från|leverantörsfaktura|levbet|faktura|kvitto|utgift)\s+/
|
||||
const LEADING_SUPPLIER_NUMBER = /^\d{1,5}\s+/
|
||||
const TRAILING_SHORT_DIGITS = /(\s+\d{1,3})+$/
|
||||
|
||||
export function ledgerKey(raw: string | null | undefined): string {
|
||||
const k = normalizeCounterpartyName(raw ?? '')
|
||||
if (!k) return ''
|
||||
const stripped = k
|
||||
.replace(AP_PREFIX, '')
|
||||
.replace(LEADING_SUPPLIER_NUMBER, '')
|
||||
.replace(TRAILING_SHORT_DIGITS, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return stripped === '' ? k : stripped
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { classifyKey, type PartyLabel } from './classify'
|
||||
|
||||
/**
|
||||
* Observed parties: counterparts derived from posted vouchers, never stored.
|
||||
* Backed by the get_observed_parties RPC (migration 20260902170000), the
|
||||
* description-keyed twin of get_ledger_deep_context for companies whose
|
||||
* history arrived by SIE import rather than bank feed.
|
||||
*
|
||||
* Each row is classified by the deterministic pre-classifier so callers can
|
||||
* route it: parties go to the register and the resolver, categories to the
|
||||
* "utan namn i texten" band, payroll and adjustments nowhere.
|
||||
*/
|
||||
export interface ObservedPartyRow {
|
||||
key: string
|
||||
name: string
|
||||
variants: string[]
|
||||
variant_count: number
|
||||
occurrences: number
|
||||
expense_sek: number
|
||||
revenue_sek: number
|
||||
first_seen: string
|
||||
last_seen: string
|
||||
cadence_days: number | null
|
||||
dominant_account_number: string | null
|
||||
dominant_account_share: number | null
|
||||
dominant_account_count: number | null
|
||||
dominant_account_total: number | null
|
||||
}
|
||||
|
||||
export interface ObservedParty extends ObservedPartyRow {
|
||||
label: PartyLabel
|
||||
/** Rough rhythm bucket derived from the median gap, for display. */
|
||||
rhythm: 'weekly' | 'monthly' | 'quarterly' | 'yearly' | 'irregular' | null
|
||||
}
|
||||
|
||||
export function rhythmFromCadence(cadenceDays: number | null): ObservedParty['rhythm'] {
|
||||
if (cadenceDays === null) return null
|
||||
if (cadenceDays >= 5 && cadenceDays <= 9) return 'weekly'
|
||||
if (cadenceDays >= 25 && cadenceDays <= 35) return 'monthly'
|
||||
if (cadenceDays >= 80 && cadenceDays <= 100) return 'quarterly'
|
||||
if (cadenceDays >= 340 && cadenceDays <= 390) return 'yearly'
|
||||
return 'irregular'
|
||||
}
|
||||
|
||||
export async function getObservedParties(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
options: { fromDate?: string | null; limit?: number; labels?: PartyLabel[] } = {},
|
||||
): Promise<ObservedParty[]> {
|
||||
const { data, error } = await supabase.rpc('get_observed_parties', {
|
||||
p_company_id: companyId,
|
||||
p_from_date: options.fromDate ?? null,
|
||||
p_limit: options.limit ?? 200,
|
||||
})
|
||||
if (error) throw new Error(`get_observed_parties failed: ${error.message}`)
|
||||
const rows = (Array.isArray(data) ? data : []) as ObservedPartyRow[]
|
||||
const wanted = options.labels ? new Set(options.labels) : null
|
||||
const out: ObservedParty[] = []
|
||||
for (const row of rows) {
|
||||
const label = classifyKey({ key: row.key, acct: row.dominant_account_number })
|
||||
if (wanted && !wanted.has(label)) continue
|
||||
out.push({ ...row, label, rhythm: rhythmFromCadence(row.cadence_days) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
* Usage:
|
||||
* npx tsx scripts/parties/eval-preclassifier.ts \
|
||||
* --golden dev_docs/parties/golden/golden-2026-09-02.jsonl \
|
||||
* --env .env.local [--out <report.json>] [--no-llm] [--batch 25] [--vocab names|full]
|
||||
* --env .env.local [--out <report.json>] [--no-llm] [--batch 25]
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
@@ -74,76 +74,15 @@ const examples = ordered.slice(0, 20)
|
||||
const exampleIds = new Set(examples.map((r) => r.id))
|
||||
const evalRows = rows.filter((r) => !exampleIds.has(r.id))
|
||||
|
||||
// ── Deterministic router v0 ─────────────────────────────────────────────────
|
||||
|
||||
// Lexicon: BAS account names and descriptions for the expense classes plus
|
||||
// the generic words that voucher text uses for a category without a
|
||||
// counterpart. Geographic tokens are deliberately NOT in the lexicon: "taxi
|
||||
// stockholm" reads as a party to the founder, "taxiresor och parkering" does
|
||||
// not.
|
||||
// ── Deterministic router ────────────────────────────────────────────────────
|
||||
// The rules live in lib/parties/classify.ts so the product and this
|
||||
// evaluation share one implementation. --vocab is kept for the report's
|
||||
// history: 'names' is what the library does; 'full' is no longer supported.
|
||||
import { classifyKey } from '@/lib/parties/classify'
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
const STOP = new Set([
|
||||
'av', 'och', 'för', 'via', 'kort', 'ej', 'moms', 'inkl', 'exkl', 'per', 'utanför', 'inom', 'eu', 'se', 'ab',
|
||||
'till', 'mot', 'med', 'från', 'på', 'i', 'en', 'ett', 'den', 'det', 'som', 'om', 'utan', 'the', 'usd', 'eur', 'sek',
|
||||
])
|
||||
const GENERIC = [
|
||||
'inköp', 'inkp', 'kvitto', 'kvitton', 'fika', 'diesel', 'bensin', 'bränsle', 'försäkring', 'telefon', 'mobil', 'hyra',
|
||||
'lokalhyra', 'frakt', 'hosting', 'julklapp', 'frimärken', 'utlägg', 'hotell', 'resa', 'resor', 'resekostnader',
|
||||
'biljett', 'biljetter', 'biljettkostnad', 'taxi', 'taxiresor', 'parkering', 'parkeringsavgifter', 'representation',
|
||||
'måltidsrepresentation', 'kollektivtrafik', 'kollektivtra', 'kontorsmaterial', 'förbrukning', 'förbrukningsmateriel',
|
||||
'frbrukningsmateriel', 'programvara', 'mjukvara', 'licens', 'avgift', 'avgifter', 'traktamente', 'traktamenten',
|
||||
'bilersättning', 'material', 'varor', 'tjänster', 'tjnster', 'faktura', 'kostnad', 'kostnader', 'betalning', 'utgift',
|
||||
'företagskvitto', 'fretagskvitto', 'fretagskvitton', 'övriga', 'personbilskostnader', 'glykol', 'lastbil', 'verktyg',
|
||||
'abonnemang', 'subscription', 'ittjänster', 'itprodukter', 'inrikes', 'utrikes', 'utlandsk', 'utländsk', 'europeisk',
|
||||
'annonsering', 'konsultarvoden', 'momspliktig', 'momsfri', 'skattefritt', 'utomlands', 'internet', 'överföring',
|
||||
'kortköputtag', 'kortkp', 'uttag', 'avdragsgill', 'avdragbar', 'schablon', 'person', 'deltagare', 'syfte', 'möte',
|
||||
'samarbete', 'rapporterad', 'kundfaktura', 'påminnelseavgifter', 'avräkningsnota', 'avrkningsnota', 'fakturaservice',
|
||||
'påminnelse', 'ränta', 'dröjsmålsränta', 'porto', 'kontor', 'lokal', 'el', 'vatten', 'värme', 'städning', 'reparation',
|
||||
'underhåll', 'service', 'utbildning', 'kurs', 'litteratur', 'tidningar', 'bok', 'böcker', 'gåva', 'gåvor', 'mat',
|
||||
'lunch', 'middag', 'kaffe', 'personal', 'friskvård', 'sjukvård', 'arbetskläder', 'skyddskläder',
|
||||
]
|
||||
// --vocab names : BAS account names + GENERIC (default; descriptions name
|
||||
// example vendors such as Google and Facebook, which makes
|
||||
// real parties look generic, the trap the July design found)
|
||||
// --vocab full : also BAS description tokens
|
||||
const vocabMode = arg('vocab') ?? 'names'
|
||||
const VOCAB = new Set<string>(GENERIC)
|
||||
for (const a of BAS_REFERENCE) {
|
||||
if (a.account_class < 4) continue
|
||||
const text = vocabMode === 'full' ? `${a.account_name} ${a.description}` : a.account_name
|
||||
for (const t of text.toLowerCase().split(/[^a-zåäöé]+/)) {
|
||||
if (t.length >= 3) VOCAB.add(t)
|
||||
}
|
||||
}
|
||||
|
||||
const AP_PREFIX = /^(levfakt|levfkt|lev\.?fakt\.?|leverantörsfaktura|leverantorsfaktura|levbet\.?|lev\.?bet\.?)\b/
|
||||
const PAYROLL = /\b(lön|löner|löne\w*|lneutbetalning|lönebesked|salary|semesterskuld|arbetsgivaravgift\w*|pensionsförsäkring)\b/
|
||||
const ADJUSTMENT =
|
||||
/(periodisering|omföring|omforing|lagerförändring|lagerforandring|nedskrivning|rättelse|rattelse|kostnadsföring|avskrivning|bokslut|kursdiff|valutakurs|eur till sek|omvänd betalningsskyldighet)/
|
||||
const BANK = /(bankkostnad|bankavgift|banktjänst|baspaket bank|bank årsavg|årsavg|avi överdrag|företagspaket)/
|
||||
const AUTHORITY = /\b(skatteverket|bolagsverket|transportstyrelsen|försäkringskassan|kronofogden|tullverket|skattekonto|kommun)\b/
|
||||
const INTERMEDIARY = /\b(klarna|paypal|zettle|izettle|swish|payex|bankgirot|adyen|nets)\b/
|
||||
|
||||
function acctNum(a: string | null): number {
|
||||
const n = Number(a)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
export function ruleLabel(row: { k: string; acct: string | null }): Label {
|
||||
const k = row.k
|
||||
const acct = acctNum(row.acct)
|
||||
if (AP_PREFIX.test(k)) return 'party'
|
||||
if (PAYROLL.test(k) || (acct >= 7010 && acct <= 7299)) return 'payroll'
|
||||
if (ADJUSTMENT.test(k)) return 'adjustment'
|
||||
if (BANK.test(k)) return 'bank'
|
||||
if (AUTHORITY.test(k)) return 'authority'
|
||||
if (INTERMEDIARY.test(k)) return 'intermediary'
|
||||
const content = k
|
||||
.split(/\s+/)
|
||||
.filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !/^k\d+$/.test(t) && !STOP.has(t))
|
||||
if (content.length === 0) return 'unsure'
|
||||
return content.every((t) => VOCAB.has(t)) ? 'category' : 'party'
|
||||
return classifyKey({ key: row.k, acct: row.acct })
|
||||
}
|
||||
|
||||
// ── Model router ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
-- Parties, phase 1b: observed parties from the books.
|
||||
--
|
||||
-- Migrants arrive with vouchers, not bank transactions, so the existing
|
||||
-- get_ledger_deep_context (keyed on transactions.merchant_name) is empty for
|
||||
-- them. This adds the description-keyed twin: a legibility key on top of the
|
||||
-- frozen normalize_counterparty_key mirror, and an RPC that aggregates posted
|
||||
-- vouchers by that key. Observed parties are never stored; the register unions
|
||||
-- this with confirmed parties and dedupes by alias key.
|
||||
--
|
||||
-- ledger_key(text) is mirrored in TypeScript by lib/parties/ledger-key.ts and
|
||||
-- the pair is pinned by tests/pg/observed-parties-rpc.pg.test.ts. Change both
|
||||
-- or neither.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.ledger_key(raw text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
PARALLEL SAFE
|
||||
AS $$
|
||||
DECLARE
|
||||
k text;
|
||||
stripped text;
|
||||
BEGIN
|
||||
k := public.normalize_counterparty_key(raw);
|
||||
IF k IS NULL OR k = '' THEN RETURN coalesce(k, ''); END IF;
|
||||
stripped := k;
|
||||
-- AP-register prefixes that Fortnox, Visma and BL put in front of the
|
||||
-- vendor. "inköp" is deliberately NOT here: stripping it turns the generic
|
||||
-- "inköp av varor" into a vendor-looking "varor" (measured 2026-07-27).
|
||||
stripped := regexp_replace(stripped, '^(levfakt|levfkt|leverantörsfaktura från|leverantörsfaktura|levbet|faktura|kvitto|utgift)\s+', '', '');
|
||||
-- Supplier number that follows the prefix ("leverantörsfaktura från 18 loopia").
|
||||
stripped := regexp_replace(stripped, '^\d{1,5}\s+', '', '');
|
||||
-- Trailing short digit runs: supplier numbers whose parentheses the
|
||||
-- normaliser already removed ("beijer byggmaterial 097", "varsego 178").
|
||||
stripped := regexp_replace(stripped, '(\s+\d{1,3})+$', '', '');
|
||||
stripped := btrim(regexp_replace(stripped, '\s+', ' ', 'g'));
|
||||
IF stripped = '' THEN RETURN k; END IF;
|
||||
RETURN stripped;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.ledger_key(text) TO authenticated, service_role;
|
||||
|
||||
-- Observed parties: posted vouchers grouped by ledger_key(description).
|
||||
-- Vouchers that carry a bank transaction with a merchant name are excluded,
|
||||
-- because get_ledger_deep_context already counts those under the bank key;
|
||||
-- the register unions the two.
|
||||
CREATE OR REPLACE FUNCTION public.get_observed_parties(
|
||||
p_company_id uuid,
|
||||
p_from_date date DEFAULT NULL,
|
||||
p_limit integer DEFAULT 200
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY INVOKER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
WITH entries AS (
|
||||
SELECT je.id, je.entry_date, je.description, public.ledger_key(je.description) AS k
|
||||
FROM public.journal_entries je
|
||||
WHERE je.company_id = p_company_id
|
||||
AND je.status = 'posted'
|
||||
AND je.source_type NOT IN ('storno', 'opening_balance', 'year_end', 'vat_settlement')
|
||||
AND (p_from_date IS NULL OR je.entry_date >= p_from_date)
|
||||
AND je.description IS NOT NULL
|
||||
AND btrim(je.description) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.transactions t
|
||||
WHERE t.journal_entry_id = je.id
|
||||
AND t.merchant_name IS NOT NULL
|
||||
AND btrim(t.merchant_name) <> ''
|
||||
)
|
||||
),
|
||||
-- Money per voucher, already SEK on journal_entry_lines: expense = debit
|
||||
-- on 4xxx-7xxx, revenue = credit on 3xxx. Vouchers with neither (pure
|
||||
-- balance-sheet movements) are not parties' business.
|
||||
money AS (
|
||||
SELECT e.id, e.k, e.entry_date, e.description,
|
||||
coalesce(sum(l.debit_amount) FILTER (WHERE l.account_number ~ '^[4-7][0-9]{3}$'), 0) AS expense_sek,
|
||||
coalesce(sum(l.credit_amount) FILTER (WHERE l.account_number ~ '^3[0-9]{3}$'), 0) AS revenue_sek
|
||||
FROM entries e
|
||||
JOIN public.journal_entry_lines l ON l.journal_entry_id = e.id
|
||||
GROUP BY e.id, e.k, e.entry_date, e.description
|
||||
HAVING coalesce(sum(l.debit_amount) FILTER (WHERE l.account_number ~ '^[4-7][0-9]{3}$'), 0) > 0
|
||||
OR coalesce(sum(l.credit_amount) FILTER (WHERE l.account_number ~ '^3[0-9]{3}$'), 0) > 0
|
||||
),
|
||||
keyed AS (SELECT * FROM money WHERE k <> ''),
|
||||
distinct_dates AS (SELECT DISTINCT k, entry_date FROM keyed),
|
||||
gaps AS (
|
||||
SELECT k, (entry_date - lag(entry_date) OVER (PARTITION BY k ORDER BY entry_date)) AS gap
|
||||
FROM distinct_dates
|
||||
),
|
||||
recur AS (
|
||||
SELECT k, round(percentile_cont(0.5) WITHIN GROUP (ORDER BY gap))::int AS cadence_days
|
||||
FROM gaps WHERE gap IS NOT NULL GROUP BY k
|
||||
),
|
||||
-- Dominant result account (3xxx-8xxx) and its Laplace-smoothed share.
|
||||
acct_counts AS (
|
||||
SELECT b.k, l.account_number, count(*)::bigint AS cnt
|
||||
FROM keyed b
|
||||
JOIN public.journal_entry_lines l ON l.journal_entry_id = b.id
|
||||
WHERE l.account_number ~ '^[3-8][0-9]{3}$'
|
||||
GROUP BY b.k, l.account_number
|
||||
),
|
||||
acct_totals AS (SELECT k, sum(cnt) AS total FROM acct_counts GROUP BY k),
|
||||
dominant AS (
|
||||
SELECT DISTINCT ON (ac.k) ac.k, ac.account_number, ac.cnt, at.total
|
||||
FROM acct_counts ac JOIN acct_totals at ON at.k = ac.k
|
||||
ORDER BY ac.k, ac.cnt DESC, ac.account_number
|
||||
),
|
||||
agg AS (
|
||||
SELECT k,
|
||||
mode() WITHIN GROUP (ORDER BY description) AS display_name,
|
||||
count(*)::bigint AS occurrences,
|
||||
count(DISTINCT description)::int AS variant_count,
|
||||
(array_agg(DISTINCT description))[1:8] AS variants,
|
||||
sum(expense_sek) AS expense_sek,
|
||||
sum(revenue_sek) AS revenue_sek,
|
||||
min(entry_date) AS first_seen,
|
||||
max(entry_date) AS last_seen
|
||||
FROM keyed
|
||||
GROUP BY k
|
||||
)
|
||||
SELECT coalesce(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'key', a.k,
|
||||
'name', a.display_name,
|
||||
'variants', to_jsonb(a.variants),
|
||||
'variant_count', a.variant_count,
|
||||
'occurrences', a.occurrences,
|
||||
'expense_sek', round(a.expense_sek)::bigint,
|
||||
'revenue_sek', round(a.revenue_sek)::bigint,
|
||||
'first_seen', a.first_seen,
|
||||
'last_seen', a.last_seen,
|
||||
'cadence_days', r.cadence_days,
|
||||
'dominant_account_number', d.account_number,
|
||||
'dominant_account_share',
|
||||
CASE WHEN d.total > 0 THEN round((d.cnt + 1)::numeric / (d.total + 2), 2) ELSE NULL END,
|
||||
'dominant_account_count', d.cnt,
|
||||
'dominant_account_total', d.total
|
||||
)
|
||||
ORDER BY (a.expense_sek + a.revenue_sek) DESC, a.occurrences DESC, a.display_name
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
FROM (
|
||||
SELECT * FROM agg
|
||||
ORDER BY (expense_sek + revenue_sek) DESC, occurrences DESC, display_name
|
||||
LIMIT greatest(1, least(coalesce(p_limit, 200), 1000))
|
||||
) a
|
||||
LEFT JOIN recur r ON r.k = a.k
|
||||
LEFT JOIN dominant d ON d.k = a.k;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.get_observed_parties(uuid, date, integer) IS
|
||||
'Observed parties from posted vouchers keyed on ledger_key(description); the description-keyed twin of get_ledger_deep_context for companies without bank feeds. Never stored.';
|
||||
|
||||
REVOKE ALL ON FUNCTION public.get_observed_parties(uuid, date, integer) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.get_observed_parties(uuid, date, integer) TO authenticated, service_role;
|
||||
@@ -0,0 +1,159 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user