feat(parties): name the company inside a voucher text, and stop asking SCB about foreign ones (#2265)

* feat(parties): name the company inside a voucher text, and stop asking SCB about foreign ones

The registry picker searched SCB on the whole display name, which for an
assistant-written voucher is a sentence, so "1511768101 · Visma Spcs AB,
faktura ..." never matched and foreign suppliers produced an empty list
with no explanation.

- lib/parties/name-extract.ts: name candidates read out of the text,
  anchored on legal-form words (AB, AB (publ), Inc., Ltd, B.V., GmbH, Oy,
  ...) and on country words, plus EU VAT numbers. Every candidate is a
  substring of the text; foreign forms and countries mark the candidate
  as one SCB cannot hold.
- Suggestions: the display name prefers the legal person named in the
  text ("TIC identity" becomes "The Intelligence Company AB (publ)"),
  the voucher texts are stored as a ledger fact for the picker, the
  country is stored when the text says, and a single foreign VAT number
  in the text becomes the party's VAT number.
- GET .../enrich/candidates plans the search: Swedish legal person first,
  cleaned head last, at most three queries, stopping at the first hit;
  no SCB call when the best reading is foreign, the response says which
  company it read and where.
- Picker: "X ser ut att vara ett utländskt bolag (Irland). SCB:s register
  täcker bara svenska företag." with a hint to save by name and VAT
  number; alternate readings offered as one-click searches when the
  first found nothing.
- nameQuery strips stacked legal-form suffixes ("AB (publ)").
- The queue builds itself whenever the books hold counterparts it has
  not seen, not only on a first visit; the toast only appears when
  something was created.

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

* fix(parties): take a text-derived VAT number only on the expense side

A customer's VAT number steers reverse charge on outgoing invoices, so it
must come from a document or a person, never from a text heuristic. A
supplier's is informational and may still be read from the voucher text.

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 13:43:41 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 7240bfe7f3
commit 82859d01db
14 changed files with 679 additions and 23 deletions
@@ -12,7 +12,10 @@ vi.mock('@/lib/company/context', () => ({
vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }) }))
const lookupByOrgNumber = vi.fn()
const searchByName = vi.fn()
vi.mock('@/lib/parties/scb/client', () => ({ createScbClient: () => ({ lookupByOrgNumber, searchByName }) }))
vi.mock('@/lib/parties/scb/client', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/parties/scb/client')>()),
createScbClient: () => ({ lookupByOrgNumber, searchByName }),
}))
const configured = { value: true }
vi.mock('@/lib/parties/scb/config', () => ({
isScbConfigured: () => configured.value,
@@ -146,18 +149,46 @@ describe('GET /api/parties/[id]/enrich/candidates', () => {
it('searches on the party name by default and on q when given', async () => {
const result = { query: 'Adobe Systems Software', mode: 'starts_with', total: 2, truncated: false, candidates: [] }
enqueue({ data: { id: PARTY, display_name: 'Adobe Systems Software', legal_name: null } })
enqueue({ data: [] })
searchByName.mockResolvedValue(result)
const a = await parseJsonResponse<{ data: typeof result }>(await candidates())
expect(a.status).toBe(200)
expect(a.body.data).toEqual(result)
expect(a.body.data).toEqual({ ...result, queries: ['Adobe Systems Software'], foreign: null })
expect(searchByName).toHaveBeenLastCalledWith('Adobe Systems Software')
enqueue({ data: { id: PARTY, display_name: 'Adobe Systems Software', legal_name: null } })
await candidates('Adobe Nordic')
expect(searchByName).toHaveBeenLastCalledWith('Adobe Nordic')
})
it('reads the legal person out of the voucher text and stops at the first query with a hit', async () => {
const miss = { query: 'The Intelligence Company', mode: 'contains', total: 0, truncated: false, 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.'] }],
})
searchByName.mockResolvedValueOnce(miss).mockResolvedValueOnce(hit)
const { status, body } = await parseJsonResponse<{ data: { query: string; queries: string[]; candidates: unknown[] } }>(await candidates())
expect(status).toBe(200)
expect(searchByName.mock.calls.map((c) => c[0])).toEqual(['The Intelligence Company', 'TIC identity'])
expect(body.data.queries).toEqual(['The Intelligence Company', 'TIC identity'])
expect(body.data.candidates).toHaveLength(1)
})
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.'] }] })
const { status, body } = await parseJsonResponse<{ data: { queries: string[]; candidates: unknown[]; foreign: unknown } }>(await candidates())
expect(status).toBe(200)
expect(searchByName).not.toHaveBeenCalled()
expect(body.data.queries).toEqual([])
expect(body.data.candidates).toEqual([])
expect(body.data.foreign).toEqual({ name: 'Framer B.V.', legalForm: 'B.V.', country: 'NL' })
})
it('maps an SCB failure to 502', async () => {
enqueue({ data: { id: PARTY, display_name: 'Adobe', legal_name: null } })
enqueue({ data: [] })
searchByName.mockRejectedValue(new Error('boom'))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await candidates())
expect(status).toBe(502)
@@ -3,14 +3,19 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { PartySearchRegistryQuerySchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createScbClient } from '@/lib/parties/scb/client'
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'
/**
* GET /api/parties/[id]/enrich/candidates?q=: SCB companies whose name
* matches, for the picker shown when a party has no org number. Never
* chooses; the user does, and the choice lands through POST .../enrich.
* matches, for the picker shown when a party has no org number. Without q
* the server plans the search itself from the party's name and voucher
* texts: the Swedish legal person named in the text first, the cleaned head
* last, and no SCB call at all when the text names a foreign company, which
* the register cannot hold. Never chooses; the user does, and the choice
* lands through POST .../enrich.
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'parties.enrich.candidates',
@@ -31,10 +36,50 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
if (error) throw new Error(`parties lookup failed: ${error.message}`)
if (!party) return errorResponseFromCode('NOT_FOUND', log, { requestId })
const p = party as { id: string; display_name: string; legal_name: string | null }
const query = validated.data.q?.trim() || p.legal_name || p.display_name
const explicit = validated.data.q?.trim()
let queries: string[]
let foreign: RegistryCandidatesResult['foreign'] = null
if (explicit) {
queries = [explicit]
} else {
const { data: textFacts, error: factsError } = await supabase
.from('party_facts')
.select('value')
.eq('company_id', companyId)
.eq('party_id', id)
.eq('field', 'voucher_text')
.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 })
queries = plan.queries
foreign = plan.foreign
}
if (queries.length === 0) {
const empty: RegistryCandidatesResult = {
query: foreign?.name ?? p.display_name,
mode: 'starts_with',
total: 0,
truncated: false,
candidates: [],
queries: [],
foreign,
}
return NextResponse.json({ data: empty })
}
try {
const result = await createScbClient(scbConfigFromEnv()).searchByName(query)
const client = createScbClient(scbConfigFromEnv())
let last: ScbSearchResult | null = null
for (const q of queries) {
last = await client.searchByName(q)
if (last.candidates.length > 0 || last.truncated) break
}
const result: RegistryCandidatesResult = { ...(last as ScbSearchResult), queries, foreign }
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) })