feat(parties): one suggestion per legal person, rename on rebuild, review list for SCB matches, model reading for memos (#2274)

* fix(parties): one suggestion per legal person, and a later run may rename an untouched one

Found while walking the queue end to end: two voucher keys naming the same
company ("TIC identity · … The Intelligence Company AB (publ)" and
"Utbetalning leverantörsfaktura …, The Intelligence Company AB (publ)")
became two suggestions and, after Lägg upp, two suppliers; and a suggestion
made before the legal-form anchoring kept its sentence-long name for good,
because apply_party_suggestions never touched a name.

- Suggestions whose display name is anchored on a legal form read out of
  the voucher text (name_anchored) are grouped: one item, both keys as
  aliases, stats summed. Such a name also attaches to an existing party
  called exactly that, legal form included, unless an org number on either
  side says otherwise. Registered company names are unique in Sweden; a
  bank memo never groups or attaches by name.
- Migration 20260904030000: apply_party_suggestions renames a suggestion
  nobody has touched (no decision, no user or registry fact) to an anchored
  name from a later run, and reports 'renamed'. Confirmed and decided
  parties keep their names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): read legal_name for exact-name attach; say a row is foreign instead of offering SCB

next build: ExistingParty had no legal_name, so the exact-legal-name index
did not compile. The query now selects it.

Queue rows whose voucher text places the company abroad show
"Utländskt bolag (Nederländerna), finns inte i SCB" instead of a search
that cannot succeed; the promote dialog counts them separately from rows
that merely lack an org number; the dossier shows the country.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): carry country on the dossier row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(parties): one review list for SCB matches, a model reading for bank memos, refresh demoted

- Review list ("Hitta org.nr (n)" in the queue toolbar): every suggestion
  SCB could hold but that lacks an org number is asked for, one row at a
  time under SCB's rate limit; rows with exactly one active match are
  shown ticked and approved in one click, the rest keep the per-row
  picker. Nothing is written before the click.
- Model reading (lib/parties/ai-name.ts, through getAiService): when the
  rules find no legal form or country in the texts, one call reads the
  counterpart out of the bank memo; kept as a 'model' fact, shown as
  "Läst ur verifikatet", used as the query, never as a hard key. On
  demand only, never when the queue builds.
- "Uppdatera förslag" moves from the page header to a ghost button in the
  toolbar: the queue builds itself now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): review list passes the dialog overflow guard; plural for match counts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): gate the model reading on the company's AI capability

Same gate as every other model call on company data: the capability the
company holds by plan and can switch off. No call, no fact, no reading
without it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

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:
Jakob Wennberg
2026-09-04 17:01:10 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent be0478c219
commit c6ca119e73
18 changed files with 1014 additions and 41 deletions
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const generateStructured = vi.fn()
const status = { configured: true }
vi.mock('@/lib/ai', () => ({
getAiService: () => ({ generateStructured }),
getAiStatus: () => ({ configured: status.configured }),
}))
import { readCounterpartName, aiNameAvailable } from '../ai-name'
beforeEach(() => {
vi.clearAllMocks()
status.configured = true
})
describe('readCounterpartName', () => {
it('reads the counterpart out of a card memo and normalises country and VAT', async () => {
generateStructured.mockResolvedValue({
value: { name: 'Booking.com', country: 'nl', vat_number: 'NL 805734958 B01', confidence: 'high' },
model: 'test-model',
usage: {},
})
const r = await readCounterpartName(['Hotel at Booking.com K3667 Kortköp/uttag · Hotell, svenskt boende, 12% moms'])
expect(r).toEqual({ name: 'Booking.com', country: 'NL', vatNumber: 'NL805734958B01', confidence: 'high', model: 'test-model' })
const req = generateStructured.mock.calls[0]![0] as { tier: string; prompt: string; schema: { name: string } }
expect(req.tier).toBe('extraction')
expect(req.prompt).toContain('1. Hotel at Booking.com')
expect(req.schema.name).toBe('counterpart_reading')
})
it('keeps a null name, drops malformed country and VAT values, and sends at most three distinct texts', async () => {
generateStructured.mockResolvedValue({ value: { name: null, country: 'Sweden', vat_number: '123', confidence: 'weird' }, model: 'm', usage: {} })
const r = await readCounterpartName(['a', 'a', 'b', 'c', 'd'])
expect(r).toEqual({ name: null, country: null, vatNumber: null, confidence: 'low', model: 'm' })
const req = generateStructured.mock.calls[0]![0] as { prompt: string }
expect(req.prompt).toContain('3. c')
expect(req.prompt).not.toContain('4. d')
})
it('answers null without a call when the deployment has no model, on empty input, on a bad answer, and on an error', async () => {
status.configured = false
expect(aiNameAvailable()).toBe(false)
expect(await readCounterpartName(['x'])).toBeNull()
expect(generateStructured).not.toHaveBeenCalled()
status.configured = true
expect(await readCounterpartName(['', ' '])).toBeNull()
generateStructured.mockResolvedValueOnce({ value: 'not an object', model: 'm', usage: {} })
expect(await readCounterpartName(['x'])).toBeNull()
generateStructured.mockRejectedValueOnce(new Error('boom'))
expect(await readCounterpartName(['x'])).toBeNull()
})
})
+39
View File
@@ -118,6 +118,45 @@ describe('buildSuggestions', () => {
expect(revenue.facts.some((f) => f.field === 'vat_number')).toBe(false)
})
it('groups keys that name the same legal person into one suggestion, and attaches to an existing party by exact legal name', () => {
const tic1 = 'TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746, The Intelligence Company AB (publ). TIC Identity-abonnemang.'
const tic2 = 'Utbetalning leverantörsfaktura 20250928, The Intelligence Company AB (publ)'
const r = buildSuggestions({
observed: [
observed({ key: 'tic identity', name: tic1, expense_sek: 2385, occurrences: 1, first_seen: '2026-02-01', last_seen: '2026-02-01' }),
observed({ key: 'utbetalning leverantörsfaktura the intelligence company publ', name: tic2, expense_sek: 2385, occurrences: 1, first_seen: '2026-03-01', last_seen: '2026-03-01' }),
],
evidence: [],
existing: [],
})
expect(r.items).toHaveLength(1)
const item = r.items[0]!
expect(item.display_name).toBe('The Intelligence Company AB (publ)')
expect(item.name_anchored).toBe(true)
expect(item.alias_keys).toEqual(['tic identity', 'utbetalning leverantörsfaktura the intelligence company publ'])
expect(item.reason.occurrences).toBe(2)
expect(item.reason.expense_sek).toBe(4770)
expect(item.reason.first_seen).toBe('2026-02-01')
expect(item.reason.last_seen).toBe('2026-03-01')
const confirmed: ExistingParty = { id: 'p-tic', display_name: 'The Intelligence Company AB (publ)', org_number: '5594871682', alias_keys: [], status: 'confirmed' }
const attached = buildSuggestions({ observed: [observed({ key: 'tic identity', name: tic1 })], evidence: [], existing: [confirmed] }).items[0]!
expect(attached.party_id).toBe('p-tic')
expect(attached.reason.attach).toBe('legal_name')
// A different org number on the key side is a different company with a confusable name.
const other = buildSuggestions({
observed: [observed({ key: 'tic identity', name: tic1 })],
evidence: [{ key: 'tic identity', docs: 1, self_docs: 0, orgs: [{ org: '5560125790', n: 1 }], vat_numbers: [], names: [], bankgiro: [], plusgiro: [] }],
existing: [confirmed],
}).items[0]!
expect(other.party_id).toBeUndefined()
// A bank memo head never groups or attaches by name.
const memo = buildSuggestions({ observed: [observed({ key: 'beijer byggmaterial', name: 'BEIJER BYGGMATERIAL 2089' })], evidence: [], existing: [{ id: 'p-b', display_name: 'BEIJER BYGGMATERIAL', org_number: null, alias_keys: [], status: 'confirmed' }] }).items[0]!
expect(memo.party_id).toBeUndefined()
expect(memo.name_anchored).toBeUndefined()
})
it('withholds the hard key and identities when a key mixes two org numbers', () => {
const r = buildSuggestions({
observed: [observed({ key: 'vattenfall' })],
+105
View File
@@ -0,0 +1,105 @@
/**
* Parties: the model reads a counterpart out of a voucher text that the
* rules could not anchor.
*
* lib/parties/name-extract.ts names a company when the text carries a legal
* form or a country word. Bank memos carry neither: "Hotel at Booking.com
* K3667 Kortköp/uttag · Hotell, svenskt boende", "UBER *TRIP HELP.UBER.COM".
* For those, and only those, one model call reads the counterpart the way a
* bookkeeper would. The reading is a fact with source 'model', shown as
* "läst ur verifikatet", used as the registry query and never as a hard key:
* an org number still comes from SCB and a person's click, a VAT number only
* when it is written in the text.
*
* Runs on demand (the picker, the review list), not when the queue builds:
* a queue of five hundred rows would otherwise cost five hundred calls that
* nobody asked for, and a rebuild would repeat them.
*/
import { z } from 'zod'
import { getAiService, getAiStatus } from '@/lib/ai'
export interface AiNameReading {
/** The counterpart as the model reads it, or null when the text names none. */
name: string | null
/** ISO 3166-1 alpha-2 when the text says where the counterpart is. */
country: string | null
/** A VAT number written in the text, if any. */
vatNumber: string | null
confidence: 'high' | 'medium' | 'low'
model: string
}
export function aiNameAvailable(): boolean {
return getAiStatus().configured
}
const SYSTEM = [
'You read descriptions of Swedish bookkeeping vouchers (verifikat) and name the counterpart: the company or organisation the money went to or came from.',
'Answer only from the text. Card memos abbreviate: "UBER *TRIP HELP.UBER.COM" is Uber, "Hotel at Booking.com" is Booking.com, "ANTHROPIC* CLAUDE SUB" is Anthropic.',
'Leave out payment method words (Kortköp/uttag, Överföring via internet, Bg-bet), references, dates, amounts, account notes and VAT commentary.',
'Give the name as the company writes it, with its legal form only if the text has it. Do not invent a legal form or an org number.',
'country: ISO 3166-1 alpha-2 only when the text states or unmistakably implies it (Ireland, (NL), USA, utländsk moms with a named country); otherwise null.',
'vat_number: only a VAT number written in the text, letters and digits, no spaces; otherwise null.',
'If the text names no counterpart (a fee, a category, a transfer between own accounts, a salary), answer name null.',
].join(' ')
const SCHEMA = {
name: 'counterpart_reading',
description: 'The counterpart named in the voucher text, or null.',
jsonSchema: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: ['string', 'null'] },
country: { type: ['string', 'null'] },
vat_number: { type: ['string', 'null'] },
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
},
required: ['name', 'country', 'vat_number', 'confidence'],
},
}
const Reading = z.object({
name: z.string().trim().min(1).max(120).nullable(),
country: z
.string()
.trim()
.transform((s) => s.toUpperCase())
.pipe(z.string().regex(/^[A-Z]{2}$/))
.nullable()
.catch(null),
vat_number: z
.string()
.trim()
.transform((s) => s.replace(/[^0-9A-Za-z]/g, '').toUpperCase())
.pipe(z.string().regex(/^[A-Z]{2}[0-9A-Z]{8,12}$/))
.nullable()
.catch(null),
confidence: z.enum(['high', 'medium', 'low']).catch('low'),
})
export const AI_NAME_MAX_TEXTS = 3
/**
* One call for one party. Returns null when the deployment has no model or
* the answer is unusable; the caller then searches on the memo as before.
*/
export async function readCounterpartName(texts: string[]): Promise<AiNameReading | null> {
const distinct = [...new Set(texts.map((t) => t.trim()).filter(Boolean))].slice(0, AI_NAME_MAX_TEXTS)
if (distinct.length === 0 || !aiNameAvailable()) return null
const prompt = ['Voucher descriptions for one counterpart:', ...distinct.map((t, i) => `${i + 1}. ${t}`)].join('\n')
try {
const result = await getAiService().generateStructured({ tier: 'extraction', system: SYSTEM, prompt, maxTokens: 200, schema: SCHEMA })
const parsed = Reading.safeParse(result.value)
if (!parsed.success) return null
return {
name: parsed.data.name,
country: parsed.data.country,
vatNumber: parsed.data.vat_number,
confidence: parsed.data.confidence,
model: result.model,
}
} catch {
return null
}
}
+25 -1
View File
@@ -56,6 +56,12 @@ export interface RegisterRow {
/** What confirming this suggestion creates, read from which side of the ledger it sits on. */
defaultRoles: PartyRole[]
createdAt: string
/**
* ISO 3166-1 alpha-2 read out of the voucher text or a register. Anything
* but SE means SCB cannot hold the party, so the queue says so instead of
* offering a search that cannot succeed.
*/
country: string | null
}
export interface ObservedRow {
@@ -234,7 +240,7 @@ export async function getRegister(
const period = options.period ?? '12m'
const q = normalizeQuery(options.q)
const [parties, customers, suppliers, observed, customerCounts, supplierCounts] = await Promise.all([
const [parties, customers, suppliers, observed, customerCounts, supplierCounts, countryFacts] = await Promise.all([
// Archived (dismissed) parties stay out of the list but keep their keys
// claimed, so a dismissed suggestion does not resurface as observed.
fetchAllRows<PartyRecord>(({ from, to }) =>
@@ -259,8 +265,24 @@ export async function getRegister(
fetchAllRows<{ supplier_id: string }>(({ from, to }) =>
supabase.from('supplier_invoices').select('supplier_id').eq('company_id', companyId).not('supplier_id', 'is', null).range(from, to),
),
fetchAllRows<{ party_id: string; value: unknown; recorded_at: string }>(({ from, to }) =>
supabase
.from('party_facts')
.select('party_id, value, recorded_at')
.eq('company_id', companyId)
.eq('field', 'country')
.is('superseded_at', null)
.order('recorded_at', { ascending: false })
.range(from, to),
),
])
const countryByParty = new Map<string, string>()
for (const f of countryFacts) {
const code = typeof f.value === 'string' ? f.value.trim().toUpperCase() : ''
if (/^[A-Z]{2}$/.test(code) && !countryByParty.has(f.party_id)) countryByParty.set(f.party_id, code)
}
const customerByParty = new Map<string, string>()
for (const c of customers) if (c.party_id && !customerByParty.has(c.party_id)) customerByParty.set(c.party_id, c.id)
const supplierByParty = new Map<string, string>()
@@ -300,6 +322,7 @@ export async function getRegister(
similar: similarById.get(p.id) ?? [],
defaultRoles: p.kind === 'person' ? ['customer'] : defaultRoles(stats),
createdAt: p.created_at,
country: countryByParty.get(p.id) ?? null,
})
}
@@ -511,6 +534,7 @@ export async function getDossier(supabase: SupabaseClient, companyId: string, pa
similar: similar.map((s) => ({ id: s.id, displayName: s.displayName })),
defaultRoles: p.kind === 'person' ? ['customer'] : defaultRoles(stats),
createdAt: p.created_at,
country: (facts.data as Array<{ field: string; value: unknown }> | null)?.find((f) => f.field === 'country' && typeof f.value === 'string')?.value as string | null ?? null,
},
facts: ((facts.data ?? []) as Array<Record<string, unknown>>).map((f) => ({
id: f.id as string,
+11
View File
@@ -28,6 +28,17 @@ export interface RegistryCandidatesResult extends ScbSearchResult {
/** Every query the server tried or would try, best first. */
queries: string[]
foreign: ForeignReading | null
/**
* What the model read out of a text the rules could not anchor (a bank
* memo), when a model is configured. Shown as "läst ur verifikatet"; the
* search ran on it. Null when the rules found a name or no model answered.
*/
aiRead: { name: string; country: string | null } | null
}
/** True when nothing in the texts anchored a name: only cleaned heads remain. */
export function needsModelReading(plan: RegistryQueryPlan): boolean {
return plan.foreign === null && plan.candidates.every((c) => c.source === 'head')
}
export const MAX_REGISTRY_QUERIES = 3
+82 -6
View File
@@ -17,6 +17,7 @@
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { roundOre } from '@/lib/money'
import { coreKey, displayNameFromVoucherText } from './ledger-key'
import { extractNameCandidates, extractVatNumbers } from './name-extract'
import { getObservedParties, type ObservedParty } from './observed'
@@ -42,6 +43,7 @@ export interface LedgerKeyEvidence {
export interface ExistingParty {
id: string
display_name: string
legal_name?: string | null
org_number: string | null
alias_keys: string[]
status: 'suggested' | 'confirmed'
@@ -64,7 +66,7 @@ export interface SuggestionIdentity {
export interface SuggestionReason {
/** How the key attaches, or why it becomes a new party. */
attach: 'party_id' | 'org_number' | 'alias_key' | 'new'
attach: 'party_id' | 'org_number' | 'alias_key' | 'legal_name' | 'new'
occurrences: number
expense_sek: number
revenue_sek: number
@@ -91,6 +93,12 @@ export interface SuggestionItem {
vat_number?: string
party_id?: string
alias_keys: string[]
/**
* The display name is the legal person named in the voucher text (a
* legal-form anchor), not a cleaned bank memo. apply_party_suggestions
* may rename an untouched suggestion to it on a later run.
*/
name_anchored?: boolean
reason: SuggestionReason
facts: SuggestionFact[]
identities: SuggestionIdentity[]
@@ -113,6 +121,8 @@ interface PickedName {
country?: string
/** The text points abroad: foreign legal form, country word or VAT prefix. */
foreign?: boolean
/** The name is a legal person read out of the text, legal form included. */
anchored?: boolean
}
/** The voucher texts under a key, most common first, at most three. */
@@ -138,7 +148,14 @@ function pickName(observed: ObservedParty, evidence: LedgerKeyEvidence | undefin
candidates.find((c) => c.source === 'legal_form' && !c.foreign) ??
candidates.find((c) => c.source === 'legal_form') ??
candidates.find((c) => c.source === 'country')
if (anchored) return { display: anchored.name, ...(anchored.country ? { country: anchored.country } : {}), foreign: anchored.foreign }
if (anchored) {
return {
display: anchored.name,
...(anchored.country ? { country: anchored.country } : {}),
foreign: anchored.foreign,
anchored: anchored.source === 'legal_form',
}
}
const head = candidates.find((c) => c.source === 'head')
return {
display: displayNameFromVoucherText(observed.name || observed.key),
@@ -172,8 +189,16 @@ export function buildSuggestions(input: {
const byOrg = new Map<string, ExistingParty>()
const byAlias = new Map<string, ExistingParty>()
const byCore = new Map<string, ExistingParty[]>()
// Exact legal names, legal form included: registered company names are
// unique in Sweden, so "Visma Spcs AB" read out of a voucher text is the
// party already called that. Never a fuzzy match; never without the form.
const byLegalName = new Map<string, ExistingParty>()
for (const p of input.existing) {
if (p.org_number && !byOrg.has(p.org_number)) byOrg.set(p.org_number, p)
for (const n of [p.display_name, p.legal_name]) {
const k = (n ?? '').trim().toLowerCase()
if (k && !byLegalName.has(k)) byLegalName.set(k, p)
}
for (const a of p.alias_keys) if (!byAlias.has(a)) byAlias.set(a, p)
const c = coreKey(p.display_name)
if (c) byCore.set(c, [...(byCore.get(c) ?? []), p])
@@ -193,10 +218,20 @@ export function buildSuggestions(input: {
const ev = evidenceByKey.get(o.key)
const orgs = ev?.orgs ?? []
const org = orgs.length === 1 ? orgs[0]!.org : undefined
const existing = (org && byOrg.get(org)) || byAlias.get(o.key) || undefined
const name = pickName(o, ev)
let existing = (org && byOrg.get(org)) || byAlias.get(o.key) || undefined
let attach: SuggestionReason['attach'] = existing ? (org && byOrg.get(org) === existing ? 'org_number' : 'alias_key') : 'new'
if (!existing && name.anchored) {
const byName = byLegalName.get(name.display.trim().toLowerCase())
// A different org number on either side means a different company
// with a confusable name; the exact-name rule never overrides a key.
if (byName && (!org || !byName.org_number || byName.org_number === org)) {
existing = byName
attach = 'legal_name'
}
}
const reason: SuggestionReason = {
attach: existing ? (org && byOrg.get(org) === existing ? 'org_number' : 'alias_key') : 'new',
attach,
occurrences: o.occurrences,
expense_sek: o.expense_sek,
revenue_sek: o.revenue_sek,
@@ -260,6 +295,7 @@ export function buildSuggestions(input: {
key: o.key,
display_name: name.display,
...(name.legal ? { legal_name: name.legal } : {}),
...(name.anchored ? { name_anchored: true } : {}),
kind: 'company',
origin: org ? 'document' : 'ledger',
...(org ? { org_number: org } : {}),
@@ -271,7 +307,47 @@ export function buildSuggestions(input: {
identities,
})
}
return { items, skipped }
return { items: groupByLegalName(items), skipped }
}
/**
* Two keys that name the same legal person, legal form included, become one
* suggestion with both keys as aliases, so "TIC identity · ... The
* Intelligence Company AB (publ)" and "Utbetalning leverantörsfaktura, The
* Intelligence Company AB (publ)" do not turn into two suppliers. Only for
* new items whose name is anchored on a legal form; hard keys and existing
* parties are already settled by then.
*/
function groupByLegalName(items: SuggestionItem[]): SuggestionItem[] {
const heads = new Map<string, SuggestionItem>()
const out: SuggestionItem[] = []
for (const item of items) {
const groupable = item.name_anchored && !item.party_id && !item.org_number
const k = groupable ? item.display_name.trim().toLowerCase() : null
const head = k ? heads.get(k) : undefined
if (!head) {
if (k) heads.set(k, item)
out.push(item)
continue
}
head.alias_keys = [...new Set([...head.alias_keys, ...item.alias_keys])]
head.reason.occurrences += item.reason.occurrences
head.reason.expense_sek = roundOre(head.reason.expense_sek + item.reason.expense_sek)
head.reason.revenue_sek = roundOre(head.reason.revenue_sek + item.reason.revenue_sek)
head.reason.docs += item.reason.docs
head.reason.self_docs += item.reason.self_docs
if (item.reason.first_seen < head.reason.first_seen) head.reason.first_seen = item.reason.first_seen
if (item.reason.last_seen > head.reason.last_seen) head.reason.last_seen = item.reason.last_seen
if (!head.vat_number && item.vat_number) head.vat_number = item.vat_number
head.identities.push(...item.identities)
const headTexts = head.facts.find((f) => f.field === 'voucher_text')
const itemTexts = item.facts.find((f) => f.field === 'voucher_text')
if (headTexts && itemTexts && Array.isArray(headTexts.value) && Array.isArray(itemTexts.value)) {
headTexts.value = [...new Set([...(headTexts.value as string[]), ...(itemTexts.value as string[])])].slice(0, 3)
}
for (const f of item.facts) if (f.field !== 'voucher_text' && !head.facts.some((h) => h.field === f.field)) head.facts.push(f)
}
return out
}
export interface SuggestSummary {
@@ -307,7 +383,7 @@ export async function suggestPartiesForCompany(
const existing = await fetchAllRows<ExistingParty>(({ from, to }) =>
supabase
.from('parties')
.select('id, display_name, org_number, alias_keys, status')
.select('id, display_name, legal_name, org_number, alias_keys, status')
.eq('company_id', companyId)
.is('merged_into', null)
.is('archived_at', null)