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
+1
View File
@@ -1568,3 +1568,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-03] Per-invoice payee migrations re-issued as 20260904010000 and 20260904011000 (were 20260903150000 / 20260903193000, merged in #2233 but never applied): the backfill's INSERT into invoice_payee_defaults fired the mirror into company_settings for a company that is a migration-reset source, whose rows are immutable by trigger, so the whole migration rolled back on prod and every later migration queued behind it. Same pattern as #2249: skip company_migration_resets sources in the backfill and re-issue under a fresh version rather than edit the failed file in place, so any environment that did apply the old version (staging, by hand) is reconciled by renaming its schema_migrations row instead of diverging silently.
[2026-09-04] ROT/RUT payout matching models the begäran (rot_rut_payout_requests), not the invoice, as the bank-row match candidate: Skatteverket pays one lump sum per begäran covering several invoices, remaining_amount is net of the deduction so a paid ROT/RUT invoice can never match, and 1513 clears per request. Confirm reuses the settle service with the transaction linked in the same call; its own dialog (RotRutPayoutMatchDialog) rather than a third branch in InvoiceMatchDialog, which carries FX/preview/edit logic this two-leg entry never needs.
[2026-09-04] Parties name extraction is rule-based first (legal-form and country anchors in lib/parties/name-extract.ts), no LLM in the batch: every candidate is a substring of the voucher text, testable and free; an AI read for the leftovers (bank memos with no anchor) waits for the founder's call on automatic vs on-click. The picker makes no SCB call when the best reading is a foreign company: the register holds Swedish legal persons only, so a search there can only mislead.
[2026-09-04] Parties: the model reads a counterpart only on demand (picker, review list), never when the queue builds: a five-hundred-row queue would cost five hundred calls nobody asked for and a rebuild would repeat them; the reading is a 'model' fact and a search query, never a hard key. The review list ticks rows with exactly one active SCB match but writes nothing until a person approves: an exact legal name plus one active hit is high precision, auto-attaching would still be the system choosing. Exact legal-form names ("Visma Spcs AB", not "Visma") group keys and attach to existing parties: registered company names are unique in Sweden, so this is a key in all but form; the "name never merges" rule keeps applying to fuzzy and form-less names.
+56 -16
View File
@@ -21,7 +21,8 @@ import { ObservedTable } from '@/components/parties/ObservedTable'
import { PartyDossier } from '@/components/parties/PartyDossier'
import { ScbPickerDialog } from '@/components/parties/ScbPickerDialog'
import type { ScbCandidate } from '@/lib/parties/scb/client'
import { SuggestionQueue } from '@/components/parties/SuggestionQueue'
import { SuggestionQueue, isForeign } from '@/components/parties/SuggestionQueue'
import { RegistryReviewDialog } from '@/components/parties/RegistryReviewDialog'
import { hasHardKey } from '@/components/parties/format'
import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -78,6 +79,7 @@ function SuggestionsPage() {
const [refreshing, setRefreshing] = useState(false)
const [fetchingRegistry, setFetchingRegistry] = useState(false)
const [picker, setPicker] = useState<{ partyId: string; name: string } | null>(null)
const [review, setReview] = useState<RegisterRow[] | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
const [dossierId, setDossierId] = useState<string | null>(null)
const [dossierReload, setDossierReload] = useState(0)
@@ -331,7 +333,13 @@ function SuggestionsPage() {
const rows = register?.rows ?? []
const searching = debounced.length > 0
const selectedItems = rows.filter((r) => selected.has(r.id)).map((r) => ({ partyId: r.id, roles: rolesFor(r) }))
const missingOrg = rows.filter((r) => selected.has(r.id) && !r.orgNumber && r.kind !== 'person').length
// Rows SCB could complete but cannot yet (no org number), and rows it can
// never hold (the text places them abroad): two different sentences.
const foreignCount = rows.filter((r) => selected.has(r.id) && isForeign(r)).length
// Rows the review list can settle: suggestions SCB could hold that have
// no org number yet. Foreign rows and people are never asked for.
const reviewable = view === 'suggested' ? rows.filter((r) => !r.orgNumber && r.kind !== 'person' && !isForeign(r)) : []
const missingOrg = rows.filter((r) => selected.has(r.id) && !r.orgNumber && r.kind !== 'person' && !isForeign(r)).length
let attn: React.ReactNode = null
if (counts && counts.suggested === 0 && counts.observed > 0 && canWrite && view === 'observed') {
@@ -405,18 +413,6 @@ function SuggestionsPage() {
<p>{t('help')}</p>
</HelpPopover>
}
action={
<Button
type="button"
variant="outline"
onClick={() => void refreshSuggestions()}
disabled={!canWrite || refreshing}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" aria-hidden="true" /> : null}
{refreshing ? t('refreshing') : t('refresh')}
</Button>
}
/>
{attn}
@@ -432,6 +428,23 @@ function SuggestionsPage() {
/>
<ToolbarSearch value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t('search_placeholder')} aria-label={t('search_placeholder')} />
<div className="ml-auto flex items-center gap-2">
{scbEnabled && canWrite && reviewable.length > 0 ? (
<Button type="button" variant="outline" size="sm" onClick={() => setReview(reviewable)} disabled={busy}>
{t('review_open', { count: reviewable.length })}
</Button>
) : null}
{/* The queue builds itself; this is for the rare manual re-run. */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void refreshSuggestions()}
disabled={!canWrite || refreshing}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" aria-hidden="true" /> : null}
{refreshing ? t('refreshing') : t('refresh')}
</Button>
<ContextPicker
items={periodItems}
value={period}
@@ -453,8 +466,14 @@ function SuggestionsPage() {
onOpenChange={setConfirmOpen}
title={t('promote_dialog_title', { count: selectedItems.length })}
description={
missingOrg > 0
? `${t('promote_dialog_body', { detail: roleSummary(t, selectedItems) })} ${t('promote_dialog_missing_org', { missing: missingOrg, count: selectedItems.length })}`
missingOrg > 0 || foreignCount > 0
? [
t('promote_dialog_body', { detail: roleSummary(t, selectedItems) }),
missingOrg > 0 ? t('promote_dialog_missing_org', { missing: missingOrg, count: selectedItems.length }) : null,
foreignCount > 0 ? t('promote_dialog_foreign', { foreign: foreignCount, count: selectedItems.length }) : null,
]
.filter(Boolean)
.join(' ')
: t('promote_dialog_body', { detail: roleSummary(t, selectedItems) })
}
confirmLabel={t('promote_n', { count: selectedItems.length })}
@@ -482,6 +501,27 @@ function SuggestionsPage() {
fetching={fetchingRegistry}
/>
{review ? (
<RegistryReviewDialog
open
rows={review}
onOpenChange={(open) => {
if (!open) {
setReview(null)
reload()
}
}}
onChoose={(row) => setPicker({ partyId: row.id, name: row.displayName })}
onApproved={(saved, failed) => {
toast({
title: t('review_done_title', { saved }),
description: failed > 0 ? `${t('review_done_description')} ${t('review_done_failed', { failed })}` : t('review_done_description'),
})
reload()
}}
/>
) : null}
{picker ? (
<ScbPickerDialog
open
@@ -16,6 +16,13 @@ vi.mock('@/lib/parties/scb/client', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/parties/scb/client')>()),
createScbClient: () => ({ lookupByOrgNumber, searchByName }),
}))
const readCounterpartName = vi.fn()
const ai = { available: false, capability: true }
vi.mock('@/lib/entitlements/has-capability', () => ({ hasCapability: () => Promise.resolve(ai.capability) }))
vi.mock('@/lib/parties/ai-name', () => ({
readCounterpartName: (texts: string[]) => readCounterpartName(texts),
aiNameAvailable: () => ai.available,
}))
const configured = { value: true }
vi.mock('@/lib/parties/scb/config', () => ({
isScbConfigured: () => configured.value,
@@ -40,6 +47,8 @@ beforeEach(() => {
reset()
eventBus.clear()
configured.value = true
ai.available = false
ai.capability = true
mockSupabase.auth.getUser.mockResolvedValue({ data: { user } })
})
@@ -153,7 +162,7 @@ describe('GET /api/parties/[id]/enrich/candidates', () => {
searchByName.mockResolvedValue(result)
const a = await parseJsonResponse<{ data: typeof result }>(await candidates())
expect(a.status).toBe(200)
expect(a.body.data).toEqual({ ...result, queries: ['Adobe Systems Software'], foreign: null })
expect(a.body.data).toEqual({ ...result, queries: ['Adobe Systems Software'], foreign: null, aiRead: null })
expect(searchByName).toHaveBeenLastCalledWith('Adobe Systems Software')
enqueue({ data: { id: PARTY, display_name: 'Adobe Systems Software', legal_name: null } })
await candidates('Adobe Nordic')
@@ -165,7 +174,7 @@ describe('GET /api/parties/[id]/enrich/candidates', () => {
const hit = { query: 'TIC identity', mode: 'starts_with', total: 1, truncated: false, candidates: [{ orgNumber: '5567890123', name: 'TIC Identity AB', active: true }] }
enqueue({ data: { id: PARTY, display_name: 'TIC identity', legal_name: null } })
enqueue({
data: [{ value: ['TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746, The Intelligence Company AB (publ). TIC Identity-abonnemang.'] }],
data: [{ field: 'voucher_text', value: ['TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746, The Intelligence Company AB (publ). TIC Identity-abonnemang.'] }],
})
searchByName.mockResolvedValueOnce(miss).mockResolvedValueOnce(hit)
const { status, body } = await parseJsonResponse<{ data: { query: string; queries: string[]; candidates: unknown[] } }>(await candidates())
@@ -175,9 +184,56 @@ describe('GET /api/parties/[id]/enrich/candidates', () => {
expect(body.data.candidates).toHaveLength(1)
})
it('lets the model read a bank memo once, keeps the reading as a fact, and searches on it', async () => {
ai.available = true
readCounterpartName.mockResolvedValue({ name: 'Booking.com', country: 'NL', vatNumber: null, confidence: 'high', model: 'm' })
enqueue({ data: { id: PARTY, display_name: 'Hotel at Booking.com', legal_name: null } })
enqueue({ data: [{ field: 'voucher_text', value: ['Hotel at Booking.com K3667 Kortköp/uttag · Hotell, svenskt boende, 12% moms'] }] })
enqueue({ data: { recorded: 1 } }) // record_party_facts
const { status, body } = await parseJsonResponse<{ data: { queries: string[]; foreign: unknown; aiRead: unknown; candidates: unknown[] } }>(await candidates())
expect(status).toBe(200)
expect(readCounterpartName).toHaveBeenCalledWith(['Hotel at Booking.com', 'Hotel at Booking.com K3667 Kortköp/uttag · Hotell, svenskt boende, 12% moms'])
// A Dutch reading: no SCB call, the picker explains, the reading is shown.
expect(searchByName).not.toHaveBeenCalled()
expect(body.data.foreign).toEqual({ name: 'Booking.com', country: 'NL' })
expect(body.data.aiRead).toEqual({ name: 'Booking.com', country: 'NL' })
const rpc = mockSupabase.rpc.mock.calls.find((c) => c[0] === 'record_party_facts')
expect(rpc?.[1]).toMatchObject({ p_source: 'model', p_party_id: PARTY, p_facts: [{ field: 'ai_name' }] })
// Cached: no second model call, and a Swedish reading is searched for.
readCounterpartName.mockClear()
enqueue({ data: { id: PARTY, display_name: 'UBER *TRIP HELP.UBER.COM', legal_name: null } })
enqueue({ data: [{ field: 'ai_name', value: { name: 'Uber Sweden AB', country: 'SE', vatNumber: null, confidence: 'high', model: 'm' } }] })
searchByName.mockResolvedValue({ query: 'Uber Sweden', mode: 'starts_with', total: 1, truncated: false, candidates: [{ orgNumber: '5567890123', name: 'Uber Sweden AB', active: true }] })
const second = await parseJsonResponse<{ data: { queries: string[]; aiRead: unknown } }>(await candidates())
expect(readCounterpartName).not.toHaveBeenCalled()
expect(searchByName).toHaveBeenLastCalledWith('Uber Sweden')
expect(second.body.data.aiRead).toEqual({ name: 'Uber Sweden AB', country: 'SE' })
})
it('does not call the model when the rules already anchored a name, when the company lacks the AI capability, or when no model is configured', async () => {
ai.available = true
enqueue({ data: { id: PARTY, display_name: 'Visma Spcs AB', legal_name: null } })
enqueue({ data: [] })
searchByName.mockResolvedValue({ query: 'Visma Spcs', mode: 'starts_with', total: 1, truncated: false, candidates: [] })
await candidates()
expect(readCounterpartName).not.toHaveBeenCalled()
ai.capability = false
enqueue({ data: { id: PARTY, display_name: 'Hotel at Booking.com', legal_name: null } })
enqueue({ data: [] })
await candidates()
expect(readCounterpartName).not.toHaveBeenCalled()
ai.capability = true
ai.available = false
enqueue({ data: { id: PARTY, display_name: 'Hotel at Booking.com', legal_name: null } })
enqueue({ data: [] })
await candidates()
expect(readCounterpartName).not.toHaveBeenCalled()
})
it('never asks SCB about a foreign company, and says which one it read', async () => {
enqueue({ data: { id: PARTY, display_name: 'Framer B.V.', legal_name: null } })
enqueue({ data: [{ value: ['Utlägg Framer · Framer B.V. (NL), webbdesignverktyg.'] }] })
enqueue({ data: [{ field: 'voucher_text', value: ['Utlägg Framer · Framer B.V. (NL), webbdesignverktyg.'] }] })
const { status, body } = await parseJsonResponse<{ data: { queries: string[]; candidates: unknown[]; foreign: unknown } }>(await candidates())
expect(status).toBe(200)
expect(searchByName).not.toHaveBeenCalled()
+51 -10
View File
@@ -6,7 +6,10 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createScbClient, type ScbSearchResult } from '@/lib/parties/scb/client'
import { isScbConfigured, scbConfigFromEnv } from '@/lib/parties/scb/config'
import { ScbApiError } from '@/lib/parties/scb/transport'
import { planRegistryQueries, type RegistryCandidatesResult } from '@/lib/parties/registry-search'
import { needsModelReading, planRegistryQueries, type RegistryCandidatesResult } from '@/lib/parties/registry-search'
import { readCounterpartName, aiNameAvailable, type AiNameReading } from '@/lib/parties/ai-name'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
/**
* GET /api/parties/[id]/enrich/candidates?q=: SCB companies whose name
@@ -19,7 +22,7 @@ import { planRegistryQueries, type RegistryCandidatesResult } from '@/lib/partie
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'parties.enrich.candidates',
async (request, { supabase, companyId, log, requestId }, { params }) => {
async (request, { supabase, companyId, user, log, requestId }, { params }) => {
const { id } = await params
if (!/^[0-9a-f-]{36}$/i.test(id)) return errorResponseFromCode('NOT_FOUND', log, { requestId })
const validated = validateQuery(request, PartySearchRegistryQuerySchema, { log, operation: 'parties.enrich.candidates' })
@@ -40,21 +43,58 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
const explicit = validated.data.q?.trim()
let queries: string[]
let foreign: RegistryCandidatesResult['foreign'] = null
let aiRead: RegistryCandidatesResult['aiRead'] = null
if (explicit) {
queries = [explicit]
} else {
const { data: textFacts, error: factsError } = await supabase
const { data: facts, error: factsError } = await supabase
.from('party_facts')
.select('value')
.select('field, value')
.eq('company_id', companyId)
.eq('party_id', id)
.eq('field', 'voucher_text')
.in('field', ['voucher_text', 'ai_name'])
.is('superseded_at', null)
if (factsError) throw new Error(`party_facts lookup failed: ${factsError.message}`)
const voucherTexts = ((textFacts ?? []) as Array<{ value: unknown }>).flatMap((f) =>
Array.isArray(f.value) ? f.value.filter((v): v is string => typeof v === 'string') : [],
)
const plan = planRegistryQueries({ legalName: p.legal_name, displayName: p.display_name, voucherTexts })
const rows = (facts ?? []) as Array<{ field: string; value: unknown }>
const voucherTexts = rows
.filter((f) => f.field === 'voucher_text')
.flatMap((f) => (Array.isArray(f.value) ? f.value.filter((v): v is string => typeof v === 'string') : []))
let plan = planRegistryQueries({ legalName: p.legal_name, displayName: p.display_name, voucherTexts })
// A bank memo the rules could not anchor: the model reads it once,
// the reading is kept as a fact with source 'model', and the search
// runs on the reading. A rebuilt queue does not repeat the call. Same
// gate as every other model call on the company's data: the AI
// capability, which the company holds by plan and can switch off.
if (needsModelReading(plan) && aiNameAvailable() && (await hasCapability(supabase, companyId, CAPABILITY.ai))) {
const cached = rows.find((f) => f.field === 'ai_name')?.value as Partial<AiNameReading> | undefined
let reading: AiNameReading | null =
cached && typeof cached === 'object' && 'name' in cached
? { name: cached.name ?? null, country: cached.country ?? null, vatNumber: cached.vatNumber ?? null, confidence: cached.confidence ?? 'low', model: cached.model ?? '' }
: null
if (!reading) {
reading = await readCounterpartName([p.display_name, ...voucherTexts])
if (reading) {
const { error: recordError } = await supabase.rpc('record_party_facts', {
p_company_id: companyId,
p_user_id: user.id,
p_party_id: id,
p_source: 'model',
p_facts: [{ field: 'ai_name', value: reading, reference: { model: reading.model, texts: voucherTexts.length } }],
p_fetched_at: new Date().toISOString(),
})
if (recordError) log.warn('record_party_facts (model reading) failed', { partyId: id, message: recordError.message })
}
}
if (reading?.name) {
aiRead = { name: reading.name, country: reading.country }
const readPlan = planRegistryQueries({ legalName: reading.name, displayName: p.display_name, voucherTexts: [] })
plan =
reading.country && reading.country !== 'SE' && !readPlan.candidates.some((c) => c.source === 'legal_form' && !c.foreign)
? { queries: [], foreign: { name: reading.name, country: reading.country }, candidates: readPlan.candidates }
: readPlan
}
}
queries = plan.queries
foreign = plan.foreign
}
@@ -68,6 +108,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
candidates: [],
queries: [],
foreign,
aiRead,
}
return NextResponse.json({ data: empty })
}
@@ -79,7 +120,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
last = await client.searchByName(q)
if (last.candidates.length > 0 || last.truncated) break
}
const result: RegistryCandidatesResult = { ...(last as ScbSearchResult), queries, foreign }
const result: RegistryCandidatesResult = { ...(last as ScbSearchResult), queries, foreign, aiRead }
return NextResponse.json({ data: result })
} catch (err) {
log.warn('scb search failed', { partyId: id, status: err instanceof ScbApiError ? err.status : undefined, message: err instanceof Error ? err.message : String(err) })
+6 -1
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import { MoreHorizontal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
@@ -12,6 +12,7 @@ import { SlideOver, SlideOverBody, SlideOverContent, SlideOverHeader } from '@/c
import type { Dossier, PartyRole, RegisterPeriod } from '@/lib/parties/register'
import { formatCurrency, formatDate, formatOrgNumber } from '@/lib/utils'
import { AccountNub } from './AccountNub'
import { regionName } from './SuggestionQueue'
import { formatPaymentIdentity, rhythmLabel, roleLabel } from './format'
import type { MergeCandidate } from './MergeDialog'
@@ -118,6 +119,7 @@ export function PartyDossier({
reloadKey: number
}) {
const t = useTranslations('parties')
const locale = useLocale()
// { partyId, reloadKey } stamps the loaded dossier, so "loading" and
// "failed" are derived instead of set from inside the effect.
const [loaded, setLoaded] = useState<{ partyId: string; reloadKey: number; dossier: Dossier | null } | null>(null)
@@ -169,6 +171,8 @@ export function PartyDossier({
}
const dominant = dossier?.facts.find((f) => f.field === 'dominant_account')?.value as { account?: string; count?: number } | undefined
const registryVat = dossier?.facts.find((f) => f.field === 'vat_number' && f.source === 'registry_scb')?.value
const countryRaw = dossier?.facts.find((f) => f.field === 'country')?.value
const countryCode = typeof countryRaw === 'string' && /^[A-Za-z]{2}$/.test(countryRaw) ? countryRaw.toUpperCase() : null
// One primary action: the role the ledger suggests and the party does not
// have yet. The other role and everything else live behind the menu.
const missingRoles: PartyRole[] = p ? (['supplier', 'customer'] as PartyRole[]).filter((r) => (r === 'supplier' ? !p.roles.supplierId : !p.roles.customerId)) : []
@@ -301,6 +305,7 @@ export function PartyDossier({
value={p.vatNumber ?? (registryVat ? String(registryVat) : <span className="text-muted-foreground">{t('fact_missing')}</span>)}
note={p.vatNumber ? docsFor('vat_number') || undefined : registryVat ? docsFor('vat_number') : undefined}
/>
{countryCode ? <Row label={t('fact_country')} value={regionName(countryCode, locale)} note={docsFor('country') || undefined} /> : null}
{dossier.identities.map((i) => (
<Row
key={i.id}
+250
View File
@@ -0,0 +1,250 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { TD_CLASS, TH_CLASS } from '@/components/ui/dry-table'
import type { ScbCandidate } from '@/lib/parties/scb/client'
import type { RegistryCandidatesResult } from '@/lib/parties/registry-search'
import type { RegisterRow } from '@/lib/parties/register'
import { formatOrgNumber } from '@/lib/utils'
import { regionName } from './SuggestionQueue'
/**
* One list instead of one picker per row. For every suggestion without an
* org number the server plans a query from the voucher text and asks SCB;
* a row whose query matches exactly one active company is shown with that
* company ticked, and the person approves the whole list at once. Zero or
* several matches, and foreign companies, are listed underneath with the
* per-row picker still available. Nothing is chosen without the click.
*
* SCB allows ten calls per ten seconds and one search is up to two calls,
* so rows are asked one at a time with a pause between them; results appear
* as they land and approval can start before the last row is in.
*/
/** Spacing between SCB rounds: two calls per search under ten per ten seconds. */
export const REVIEW_STEP_MS = 2200
export type ReviewOutcome =
| { kind: 'match'; candidate: ScbCandidate; aiRead: RegistryCandidatesResult['aiRead'] }
| { kind: 'choose'; count: number; aiRead: RegistryCandidatesResult['aiRead'] }
| { kind: 'none'; aiRead: RegistryCandidatesResult['aiRead'] }
| { kind: 'foreign'; name: string; country: string | null }
| { kind: 'failed' }
export interface ReviewState {
row: RegisterRow
outcome: ReviewOutcome | null
approved: boolean
saved: 'pending' | 'saving' | 'done' | 'failed'
}
export function outcomeOf(result: RegistryCandidatesResult): ReviewOutcome {
if (result.foreign && result.candidates.length === 0) return { kind: 'foreign', name: result.foreign.name, country: result.foreign.country ?? null }
const active = result.candidates.filter((c) => c.active)
if (!result.truncated && active.length === 1 && result.candidates.length === 1) return { kind: 'match', candidate: active[0]!, aiRead: result.aiRead }
if (result.candidates.length === 0) return { kind: 'none', aiRead: result.aiRead }
return { kind: 'choose', count: result.truncated ? result.total : result.candidates.length, aiRead: result.aiRead }
}
export function RegistryReviewDialog({
open,
rows,
onOpenChange,
onChoose,
onApproved,
delayMs = REVIEW_STEP_MS,
}: {
open: boolean
rows: RegisterRow[]
onOpenChange: (open: boolean) => void
/** Open the per-row picker for a row the list could not settle. */
onChoose: (row: RegisterRow) => void
/** Called once after the approved org numbers are saved. */
onApproved: (saved: number, failed: number) => void
delayMs?: number
}) {
const t = useTranslations('parties')
const tCommon = useTranslations('common')
const locale = useLocale()
const [states, setStates] = useState<ReviewState[]>([])
const [searched, setSearched] = useState(0)
const [saving, setSaving] = useState(false)
const runId = useRef(0)
// Ask SCB row by row while the dialog is open; a closed dialog stops the loop.
useEffect(() => {
if (!open) return
const id = ++runId.current
const initial: ReviewState[] = rows.map((row) => ({ row, outcome: null, approved: false, saved: 'pending' }))
setStates(initial)
setSearched(0)
let cancelled = false
void (async () => {
for (let i = 0; i < rows.length; i += 1) {
if (cancelled || runId.current !== id) return
const row = rows[i]!
let outcome: ReviewOutcome
try {
const res = await fetch(`/api/parties/${row.id}/enrich/candidates`)
if (!res.ok) throw new Error(String(res.status))
const json = (await res.json()) as { data: RegistryCandidatesResult }
outcome = outcomeOf(json.data)
} catch {
outcome = { kind: 'failed' }
}
if (cancelled || runId.current !== id) return
setStates((prev) => prev.map((s) => (s.row.id === row.id ? { ...s, outcome, approved: outcome.kind === 'match' } : s)))
setSearched(i + 1)
if (i < rows.length - 1) await new Promise((r) => setTimeout(r, delayMs))
}
})()
return () => {
cancelled = true
}
// rows is the snapshot the dialog was opened with; re-running on every
// register reload would restart the SCB loop mid-way.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, delayMs])
const searching = open && searched < rows.length
const matches = states.filter((s) => s.outcome?.kind === 'match')
const others = states.filter((s) => s.outcome && s.outcome.kind !== 'match')
const approvedCount = matches.filter((s) => s.approved && s.saved === 'pending').length
async function approve() {
const chosen = matches.filter((s) => s.approved && s.saved === 'pending')
if (chosen.length === 0) return
setSaving(true)
let saved = 0
let failed = 0
for (let i = 0; i < chosen.length; i += 1) {
const s = chosen[i]!
const candidate = (s.outcome as Extract<ReviewOutcome, { kind: 'match' }>).candidate
setStates((prev) => prev.map((x) => (x.row.id === s.row.id ? { ...x, saved: 'saving' } : x)))
let ok = false
try {
const res = await fetch(`/api/parties/${s.row.id}/enrich`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orgNumber: candidate.orgNumber }),
})
ok = res.ok
} catch {
ok = false
}
if (ok) saved += 1
else failed += 1
setStates((prev) => prev.map((x) => (x.row.id === s.row.id ? { ...x, saved: ok ? 'done' : 'failed' } : x)))
if (i < chosen.length - 1) await new Promise((r) => setTimeout(r, delayMs / 2))
}
setSaving(false)
onApproved(saved, failed)
}
return (
<Dialog open={open} onOpenChange={(o) => (!saving ? onOpenChange(o) : undefined)}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{t('review_title')}</DialogTitle>
<DialogDescription>{searching ? t('review_searching', { done: searched, total: rows.length }) : t('review_intro')}</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-6 overflow-y-auto">
{matches.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead>
<tr>
<th className={`${TH_CLASS} w-8`} />
<th className={TH_CLASS}>{t('review_col_book')}</th>
<th className={TH_CLASS}>{t('review_col_match')}</th>
<th className={TH_CLASS} />
</tr>
</thead>
<tbody>
{matches.map((s) => {
const o = s.outcome as Extract<ReviewOutcome, { kind: 'match' }>
return (
<tr key={s.row.id}>
<td className={TD_CLASS}>
<Checkbox
checked={s.approved}
disabled={s.saved !== 'pending' || saving}
onCheckedChange={(v) => setStates((prev) => prev.map((x) => (x.row.id === s.row.id ? { ...x, approved: v === true } : x)))}
aria-label={s.row.displayName}
/>
</td>
<td className={`${TD_CLASS} max-w-[16rem] truncate`} title={s.row.displayName}>
{s.row.displayName}
{o.aiRead ? <span className="block text-xs text-muted-foreground">{t('review_ai_read', { name: o.aiRead.name })}</span> : null}
</td>
<td className={TD_CLASS}>
<span className="font-medium">{o.candidate.name}</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{[formatOrgNumber(o.candidate.orgNumber), o.candidate.city, o.candidate.industry].filter(Boolean).join(' · ')}
</span>
</td>
<td className={`${TD_CLASS} text-right`}>
{s.saved === 'done' ? <Badge variant="success">{t('review_saved')}</Badge> : s.saved === 'failed' ? <Badge variant="warning">{t('review_failed')}</Badge> : null}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : null}
{others.length > 0 ? (
<div className="space-y-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t('review_others')}</p>
<ul className="divide-y divide-border rounded-lg border border-border">
{others.map((s) => {
const o = s.outcome!
return (
<li key={s.row.id} className="flex items-center gap-3 px-4 py-2 text-[13px]">
<span className="min-w-0 flex-1 truncate" title={s.row.displayName}>
{s.row.displayName}
{'aiRead' in o && o.aiRead ? <span className="block text-xs text-muted-foreground">{t('review_ai_read', { name: o.aiRead.name })}</span> : null}
</span>
<span className="text-muted-foreground">
{o.kind === 'foreign'
? t('review_foreign', { place: o.country ? ` (${regionName(o.country, locale)})` : '' })
: o.kind === 'choose'
? t('review_choose_n', { count: o.count })
: o.kind === 'failed'
? t('registry_unavailable_title')
: t('review_none')}
</span>
{o.kind === 'choose' || o.kind === 'none' ? (
<Button type="button" size="sm" variant="outline" onClick={() => onChoose(s.row)} disabled={saving}>
{t('review_choose')}
</Button>
) : null}
</li>
)
})}
</ul>
</div>
) : null}
{!searching && matches.length === 0 && others.length === 0 ? <p className="text-sm text-muted-foreground">{t('review_empty')}</p> : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{tCommon('close')}
</Button>
<Button type="button" onClick={() => void approve()} disabled={saving || approvedCount === 0}>
{saving ? t('review_approving') : t('review_approve', { count: approvedCount })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+1
View File
@@ -98,6 +98,7 @@ export function ScbPickerDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{result?.aiRead && !query.trim() && !loading ? <p className="text-sm text-muted-foreground">{t('picker_ai_read', { name: result.aiRead.name })}</p> : null}
{foreign && candidates.length === 0 && !loading ? <p className="text-sm text-muted-foreground">{t('picker_foreign_hint')}</p> : null}
{alternates.length > 0 && !loading ? (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
+21 -2
View File
@@ -1,6 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import { Check, ChevronDown } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -17,6 +17,19 @@ import { isDuplicateCandidate, reasonText, rolesLabel } from './format'
* here and what it becomes; only rows with a hard key arrive pre-ticked;
* bulk confirm opens one dialog that says what happens.
*/
/** A party the voucher text places abroad: SCB cannot hold it, so no search is offered. */
export function isForeign(row: { country: string | null }): boolean {
return !!row.country && row.country !== 'SE'
}
export function regionName(code: string, locale: string): string {
try {
return new Intl.DisplayNames([locale], { type: 'region' }).of(code) ?? code
} catch {
return code
}
}
export function SuggestionQueue({
rows,
selected,
@@ -48,6 +61,7 @@ export function SuggestionQueue({
onFind?: (row: RegisterRow) => void
}) {
const t = useTranslations('parties')
const locale = useLocale()
const count = selected.size
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id))
@@ -113,7 +127,12 @@ export function SuggestionQueue({
</td>
<td className={`${TD_CLASS} min-w-[16rem] max-w-[28rem] text-muted-foreground`}>
{reasonText(t, row.reason, row.stats?.rhythm ?? null, row.orgNumber)}
{onFind && !row.orgNumber && row.kind !== 'person' ? (
{isForeign(row) ? (
<>
{' · '}
<span className="text-foreground">{t('row_foreign', { country: regionName(row.country as string, locale) })}</span>
</>
) : onFind && !row.orgNumber && row.kind !== 'person' ? (
<>
{' · '}
<button
+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)
+24
View File
@@ -8521,6 +8521,30 @@
"picker_foreign": "{name} looks like a foreign company{place}. The SCB register only covers Swedish companies.",
"picker_foreign_hint": "Add the contact with its name and VAT number instead, or search on another name if it is a Swedish company after all.",
"picker_try_instead": "Try instead",
"row_foreign": "Foreign company ({country}), not in the SCB register",
"promote_dialog_foreign": "{foreign} of {count} are foreign companies not in the SCB register; name and VAT number come from the documents.",
"fact_country": "Country",
"review_open": "Find org numbers ({count})",
"review_title": "Find org numbers in the company register",
"review_searching": "Asking SCB, {done} of {total} …",
"review_intro": "SCB was asked with the name in the voucher text. Rows with exactly one match are shown ticked; nothing is chosen until you approve.",
"review_col_book": "In the books",
"review_col_match": "Match in SCB",
"review_others": "Needs a choice or cannot be found",
"review_choose": "Choose",
"review_choose_n": "{count, plural, one {1 match} other {# matches}}",
"review_none": "No match",
"review_foreign": "Foreign company{place}",
"review_ai_read": "Read from the voucher: {name}",
"review_approve": "Approve {count}",
"review_approving": "Saving …",
"review_saved": "Saved",
"review_failed": "Failed",
"review_done_title": "{saved} org numbers saved",
"review_done_description": "The SCB details are fetched when you add them to the register.",
"review_done_failed": "{failed} could not be saved.",
"review_empty": "No rows are missing an org number.",
"picker_ai_read": "Read from the voucher: {name}.",
"fact_trade_name": "Trade name",
"open_dossier": "Open {name}",
"attn_create": "Create suggestions",
+24
View File
@@ -8521,6 +8521,30 @@
"picker_foreign": "{name} ser ut att vara ett utländskt bolag{place}. SCB:s register täcker bara svenska företag.",
"picker_foreign_hint": "Lägg upp kontakten med namn och momsnummer i stället, eller sök på ett annat namn om det ändå är ett svenskt bolag.",
"picker_try_instead": "Sök i stället på",
"row_foreign": "Utländskt bolag ({country}), finns inte i SCB",
"promote_dialog_foreign": "{foreign} av {count} är utländska bolag och finns inte i SCB; namn och momsnummer kommer från underlagen.",
"fact_country": "Land",
"review_open": "Hitta org.nr ({count})",
"review_title": "Hitta org.nr i företagsregistret",
"review_searching": "Frågar SCB, {done} av {total} …",
"review_intro": "SCB frågades med namnet i verifikatet. Rader med exakt en träff visas ikryssade; ingenting väljs utan att du godkänner.",
"review_col_book": "I bokföringen",
"review_col_match": "Träff i SCB",
"review_others": "Behöver ett val eller går inte att hitta",
"review_choose": "Välj",
"review_choose_n": "{count, plural, one {1 träff} other {# träffar}}",
"review_none": "Ingen träff",
"review_foreign": "Utländskt bolag{place}",
"review_ai_read": "Läst ur verifikatet: {name}",
"review_approve": "Godkänn {count}",
"review_approving": "Sparar …",
"review_saved": "Sparat",
"review_failed": "Misslyckades",
"review_done_title": "{saved} org.nr sparade",
"review_done_description": "Uppgifterna från SCB hämtas när du lägger upp dem.",
"review_done_failed": "{failed} gick inte att spara.",
"review_empty": "Inga rader saknar org.nr.",
"picker_ai_read": "Läst ur verifikatet: {name}.",
"fact_trade_name": "Firma",
"open_dossier": "Öppna {name}",
"attn_create": "Skapa förslag",
@@ -0,0 +1,179 @@
-- Parties: a later suggestion run may rename an untouched suggestion.
--
-- apply_party_suggestions only ever added aliases, hard keys and facts to an
-- existing party, never a name, so a suggestion made before the legal-form
-- anchoring (feat/parties-name-extract) kept its sentence-long or bank-memo
-- name for good: on 2026-09-04 that was 4 rows in one company and 510 in
-- another. Now an item whose display name is anchored on a legal form read
-- out of the voucher text (name_anchored) renames a party that is still a
-- suggestion nobody has touched: no decision, no user or registry fact.
-- Confirmed parties, decided ones and names a person or a register gave are
-- never renamed. The summary gains 'renamed'.
CREATE OR REPLACE FUNCTION public.apply_party_suggestions(
p_company_id uuid,
p_user_id uuid,
p_items jsonb
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path TO 'public'
AS $$
DECLARE
v_item jsonb;
v_fact jsonb;
v_ident jsonb;
v_party_id uuid;
v_key text;
v_org text;
v_vat text;
v_aliases text[];
v_created integer := 0;
v_attached integer := 0;
v_identities integer := 0;
v_facts integer := 0;
v_seen integer;
v_rename boolean;
v_renamed integer := 0;
BEGIN
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
RAISE EXCEPTION 'apply_party_suggestions: p_user_id must be the caller' USING ERRCODE = '42501';
END IF;
IF p_items IS NULL OR jsonb_typeof(p_items) <> 'array' THEN
RAISE EXCEPTION 'apply_party_suggestions: p_items must be a JSON array' USING ERRCODE = '22023';
END IF;
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) LOOP
v_key := nullif(btrim(coalesce(v_item->>'key', '')), '');
IF v_key IS NULL THEN
RAISE EXCEPTION 'apply_party_suggestions: every item needs a key' USING ERRCODE = '22023';
END IF;
v_org := public.normalize_org_number(v_item->>'org_number');
v_vat := nullif(upper(regexp_replace(coalesce(v_item->>'vat_number', ''), '[^0-9A-Za-z]', '', 'g')), '');
v_aliases := ARRAY(SELECT DISTINCT x FROM (
SELECT v_key AS x UNION ALL SELECT jsonb_array_elements_text(coalesce(v_item->'alias_keys', '[]'::jsonb))
) a WHERE x IS NOT NULL AND btrim(x) <> '');
v_party_id := NULL;
IF v_item->>'party_id' IS NOT NULL THEN
SELECT id INTO v_party_id FROM public.parties
WHERE id = (v_item->>'party_id')::uuid AND company_id = p_company_id AND merged_into IS NULL;
IF v_party_id IS NULL THEN
RAISE EXCEPTION 'apply_party_suggestions: party % is not a live party of this company', v_item->>'party_id'
USING ERRCODE = '23503';
END IF;
END IF;
IF v_party_id IS NULL AND v_org IS NOT NULL THEN
SELECT id INTO v_party_id FROM public.parties
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL;
END IF;
-- A VAT number is a hard key too: foreign suppliers (Framer B.V.,
-- Anthropic Ireland) never carry a Swedish org number, and three keys for
-- one Dutch company made three suggestions on 2026-09-03.
IF v_party_id IS NULL AND v_vat IS NOT NULL THEN
SELECT id INTO v_party_id FROM public.parties
WHERE company_id = p_company_id AND merged_into IS NULL
AND upper(regexp_replace(coalesce(vat_number, ''), '[^0-9A-Za-z]', '', 'g')) = v_vat
ORDER BY (status = 'confirmed') DESC, created_at
LIMIT 1;
END IF;
IF v_party_id IS NULL THEN
SELECT id INTO v_party_id FROM public.parties
WHERE company_id = p_company_id AND merged_into IS NULL AND alias_keys @> ARRAY[v_key]
ORDER BY (status = 'confirmed') DESC, created_at
LIMIT 1;
END IF;
IF v_party_id IS NULL THEN
INSERT INTO public.parties (company_id, user_id, display_name, legal_name, kind, status, org_number, vat_number, alias_keys, origin, suggested_reason)
VALUES (
p_company_id, p_user_id,
coalesce(nullif(btrim(v_item->>'display_name'), ''), v_key),
nullif(btrim(v_item->>'legal_name'), ''),
coalesce(v_item->>'kind', 'company'),
'suggested',
v_org,
nullif(btrim(v_item->>'vat_number'), ''),
v_aliases,
coalesce(v_item->>'origin', 'ledger'),
v_item->'reason'
)
ON CONFLICT DO NOTHING
RETURNING id INTO v_party_id;
IF v_party_id IS NULL THEN
-- Lost a race on (company_id, org_number): attach to the winner.
SELECT id INTO v_party_id FROM public.parties
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL;
v_attached := v_attached + 1;
ELSE
v_created := v_created + 1;
END IF;
ELSE
v_attached := v_attached + 1;
-- An untouched suggestion (no decision, no user or registry fact) takes
-- a better name from a later run when that name is anchored on a legal
-- form read out of the voucher text; a cleaned bank memo never
-- replaces anything. Names a person or a register gave stay.
v_rename := coalesce((v_item->>'name_anchored')::boolean, false)
AND nullif(btrim(v_item->>'display_name'), '') IS NOT NULL
AND EXISTS (
SELECT 1 FROM public.parties p
WHERE p.id = v_party_id AND p.status = 'suggested'
AND p.display_name IS DISTINCT FROM btrim(v_item->>'display_name')
AND NOT EXISTS (SELECT 1 FROM public.party_decisions d WHERE d.party_id = p.id)
AND NOT EXISTS (SELECT 1 FROM public.party_facts f WHERE f.party_id = p.id AND f.source IN ('user', 'registry_scb', 'registry_tic', 'vies', 'peppol'))
);
UPDATE public.parties
SET alias_keys = ARRAY(SELECT DISTINCT x FROM unnest(alias_keys || v_aliases) AS x),
vat_number = coalesce(vat_number, nullif(btrim(v_item->>'vat_number'), '')),
legal_name = coalesce(legal_name, nullif(btrim(v_item->>'legal_name'), '')),
org_number = coalesce(org_number, v_org),
display_name = CASE WHEN v_rename THEN btrim(v_item->>'display_name') ELSE display_name END
WHERE id = v_party_id
AND (NOT (alias_keys @> v_aliases)
OR v_rename
OR (vat_number IS NULL AND nullif(btrim(v_item->>'vat_number'), '') IS NOT NULL)
OR (legal_name IS NULL AND nullif(btrim(v_item->>'legal_name'), '') IS NOT NULL)
OR (org_number IS NULL AND v_org IS NOT NULL));
IF v_rename THEN v_renamed := v_renamed + 1; END IF;
END IF;
FOR v_ident IN SELECT * FROM jsonb_array_elements(coalesce(v_item->'identities', '[]'::jsonb)) LOOP
v_seen := greatest(coalesce((v_ident->>'seen_count')::integer, 1), 1);
INSERT INTO public.party_identities (party_id, company_id, user_id, scheme, value, status, source, first_seen, last_seen, seen_count)
VALUES (
v_party_id, p_company_id, p_user_id,
v_ident->>'scheme', v_ident->>'value',
CASE WHEN v_seen >= 2 THEN 'known' ELSE 'unverified' END,
coalesce(v_ident->>'source', 'document'),
(v_ident->>'first_seen')::date, (v_ident->>'last_seen')::date, v_seen
)
ON CONFLICT (party_id, scheme, value) DO UPDATE
SET seen_count = greatest(party_identities.seen_count, EXCLUDED.seen_count),
first_seen = least(party_identities.first_seen, EXCLUDED.first_seen),
last_seen = greatest(party_identities.last_seen, EXCLUDED.last_seen),
status = CASE WHEN greatest(party_identities.seen_count, EXCLUDED.seen_count) >= 2 THEN 'known' ELSE party_identities.status END
WHERE party_identities.seen_count < EXCLUDED.seen_count
OR party_identities.last_seen IS DISTINCT FROM greatest(party_identities.last_seen, EXCLUDED.last_seen)
OR party_identities.first_seen IS DISTINCT FROM least(party_identities.first_seen, EXCLUDED.first_seen);
v_identities := v_identities + 1;
END LOOP;
FOR v_fact IN SELECT * FROM jsonb_array_elements(coalesce(v_item->'facts', '[]'::jsonb)) LOOP
INSERT INTO public.party_facts (party_id, company_id, user_id, field, value, source, reference)
SELECT v_party_id, p_company_id, p_user_id, v_fact->>'field', v_fact->'value', v_fact->>'source', v_fact->'reference'
WHERE NOT EXISTS (
SELECT 1 FROM public.party_facts f
WHERE f.party_id = v_party_id AND f.field = v_fact->>'field' AND f.source = v_fact->>'source'
AND f.value = v_fact->'value' AND f.superseded_at IS NULL
);
IF FOUND THEN v_facts := v_facts + 1; END IF;
END LOOP;
END LOOP;
RETURN jsonb_build_object('created', v_created, 'attached', v_attached, 'identities', v_identities, 'facts', v_facts, 'renamed', v_renamed);
END;
$$;
NOTIFY pgrst, 'reload schema';
+27 -2
View File
@@ -127,7 +127,7 @@ describe('apply_party_suggestions (pg)', () => {
})
const first = await apply(c.companyId, c.userId, [item({ org_number: '556666-1012' })])
expect(first).toEqual({ created: 1, attached: 0, identities: 1, facts: 1 })
expect(first).toEqual({ created: 1, attached: 0, identities: 1, facts: 1, renamed: 0 })
const party = await getPool().query<{ id: string; status: string; org_number: string; alias_keys: string[]; suggested_reason: { attach: string }; origin: string }>(
`SELECT id, status, org_number, alias_keys, suggested_reason, origin FROM public.parties WHERE company_id = $1`,
[c.companyId],
@@ -141,7 +141,7 @@ describe('apply_party_suggestions (pg)', () => {
const second = await apply(c.companyId, c.userId, [
item({ key: 'loopia webbhotell', alias_keys: ['loopia webbhotell'], org_number: LOOPIA_ORG, identities: [{ scheme: 'bankgiro', value: '53170900', first_seen: '2026-03-10', last_seen: '2026-03-10', seen_count: 2 }] }),
])
expect(second).toEqual({ created: 0, attached: 1, identities: 1, facts: 0 })
expect(second).toEqual({ created: 0, attached: 1, identities: 1, facts: 0, renamed: 0 })
const after = await getPool().query<{ n: string; alias_keys: string[] }>(
`SELECT (SELECT count(*) FROM public.parties WHERE company_id = $1)::text AS n, alias_keys FROM public.parties WHERE id = $2`,
[c.companyId, partyId],
@@ -174,6 +174,31 @@ describe('apply_party_suggestions (pg)', () => {
expect([...rows.rows[0]!.alias_keys].sort()).toEqual(['framer utlägg', 'utlägg framer'])
})
it('renames an untouched suggestion to a legal-form anchored name on a later run, never a decided one or a memo', async () => {
const c = await seedCompany()
await apply(c.companyId, c.userId, [{ key: 'tic identity', display_name: 'TIC identity' }])
const renamed = await apply(c.companyId, c.userId, [
{ key: 'tic identity', display_name: 'The Intelligence Company AB (publ)', name_anchored: true },
])
expect(renamed).toMatchObject({ created: 0, attached: 1, renamed: 1 })
const after = await getPool().query<{ id: string; display_name: string }>(
`SELECT id, display_name FROM public.parties WHERE company_id = $1`,
[c.companyId],
)
expect(after.rows).toHaveLength(1)
expect(after.rows[0]!.display_name).toBe('The Intelligence Company AB (publ)')
// A memo-shaped name (not anchored) never replaces anything.
const memo = await apply(c.companyId, c.userId, [{ key: 'tic identity', display_name: 'TIC IDENTITY BG 0000005786439' }])
expect(memo).toMatchObject({ renamed: 0 })
// Once a person has decided on the row, no later run touches its name.
await decide(c.companyId, c.userId, [after.rows[0]!.id], 'confirm')
const decided = await apply(c.companyId, c.userId, [{ key: 'tic identity', display_name: 'The Intelligence Company Nordic AB', name_anchored: true }])
expect(decided).toMatchObject({ renamed: 0 })
const final = await getPool().query<{ display_name: string }>(`SELECT display_name FROM public.parties WHERE company_id = $1`, [c.companyId])
expect(final.rows[0]!.display_name).toBe('The Intelligence Company AB (publ)')
})
it('never merges on name: same core text becomes a second suggested party', async () => {
const c = await seedCompany()
await apply(c.companyId, c.userId, [{ key: 'fortnox', display_name: 'Fortnox AB', org_number: ORG }])