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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
7240bfe7f3
commit
82859d01db
@@ -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) })
|
||||
|
||||
Reference in New Issue
Block a user