From 82859d01dba9a66cc1d1072521afc6772dbfcd26 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 4 Sep 2026 13:43:41 +0200 Subject: [PATCH] feat(parties): name the company inside a voucher text, and stop asking SCB about foreign ones (#2265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + app/(dashboard)/parties/page.tsx | 12 +- .../[id]/enrich/__tests__/route.test.ts | 35 ++- .../parties/[id]/enrich/candidates/route.ts | 55 +++- components/parties/ScbPickerDialog.tsx | 37 ++- lib/parties/__tests__/name-extract.test.ts | 89 ++++++ lib/parties/__tests__/registry-search.test.ts | 46 +++ lib/parties/__tests__/suggest.test.ts | 16 +- lib/parties/name-extract.ts | 279 ++++++++++++++++++ lib/parties/registry-search.ts | 65 ++++ lib/parties/scb/client.ts | 2 +- lib/parties/suggest.ts | 59 +++- messages/en.json | 3 + messages/sv.json | 3 + 14 files changed, 679 insertions(+), 23 deletions(-) create mode 100644 lib/parties/__tests__/name-extract.test.ts create mode 100644 lib/parties/__tests__/registry-search.test.ts create mode 100644 lib/parties/name-extract.ts create mode 100644 lib/parties/registry-search.ts diff --git a/DECISIONS.md b/DECISIONS.md index b3692bd3..0bd6fea3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1564,3 +1564,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-03] Decision lines for PRs #2242 (#2237), #2246 (#2203) and #2245 (#2214) are carried in this PR's commit rather than their own: DECISIONS.md is append-only, so every squash-merge flips every other open PR to CONFLICTING and costs a full CI round each; consolidating the lines into the last PR of the batch turns four rounds into one. [2026-09-03] The country backfill ships as 20260903173000, not 20260903170000: the first version failed on prod at the customers UPDATE because rows of a migration-reset source company are immutable by trigger (block_migration_reset_source_mutation), and the whole file rolled back. The new file skips those companies in every UPDATE (their legacy text is still normalised at read time) and the old file is removed rather than edited, since prod never recorded it; staging was re-tracked by hand under the new version. [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] 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. diff --git a/app/(dashboard)/parties/page.tsx b/app/(dashboard)/parties/page.tsx index 3e44ba57..fd3e25f6 100644 --- a/app/(dashboard)/parties/page.tsx +++ b/app/(dashboard)/parties/page.tsx @@ -124,12 +124,12 @@ function SuggestionsPage() { setDossierReload((k) => k + 1) }, []) - // First visit for a company whose books name counterparts nobody has - // registered: build the queue right away instead of asking for a click - // whose effect nobody could guess. Suggestions only, reversible. + // The books name counterparts the queue has not seen yet: build the + // suggestions right away instead of asking for a click whose effect nobody + // could guess. Once per visit, suggestions only, reversible. useEffect(() => { if (!register || autoRan.current || !canWrite || debounced) return - if (register.counts.suggested === 0 && register.counts.observed > 0) { + if (register.counts.observed > 0) { autoRan.current = true void refreshSuggestions(true) } @@ -163,7 +163,9 @@ function SuggestionsPage() { setRefreshing(true) try { const summary = await post<{ created: number; attached: number }>('/api/parties/suggest') - if (auto) toast({ title: t('auto_created_title', { count: summary.created }), description: t('auto_created_description') }) + if (auto) { + if (summary.created > 0) toast({ title: t('auto_created_title', { count: summary.created }), description: t('auto_created_description') }) + } else toast({ title: t('refreshed_title'), description: t('refreshed_description', { created: summary.created, attached: summary.attached }) }) reload() } catch { diff --git a/app/api/parties/[id]/enrich/__tests__/route.test.ts b/app/api/parties/[id]/enrich/__tests__/route.test.ts index de1b7f68..5939b8c8 100644 --- a/app/api/parties/[id]/enrich/__tests__/route.test.ts +++ b/app/api/parties/[id]/enrich/__tests__/route.test.ts @@ -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()), + 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) diff --git a/app/api/parties/[id]/enrich/candidates/route.ts b/app/api/parties/[id]/enrich/candidates/route.ts index 3a830572..d4a9daa2 100644 --- a/app/api/parties/[id]/enrich/candidates/route.ts +++ b/app/api/parties/[id]/enrich/candidates/route.ts @@ -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) }) diff --git a/components/parties/ScbPickerDialog.tsx b/components/parties/ScbPickerDialog.tsx index 1b485e0d..b3d7719a 100644 --- a/components/parties/ScbPickerDialog.tsx +++ b/components/parties/ScbPickerDialog.tsx @@ -1,15 +1,24 @@ 'use client' import { useEffect, useState } from 'react' -import { useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' -import type { ScbCandidate, ScbSearchResult } from '@/lib/parties/scb/client' +import type { ScbCandidate } from '@/lib/parties/scb/client' +import type { RegistryCandidatesResult } from '@/lib/parties/registry-search' import { formatOrgNumber } from '@/lib/utils' +function regionName(code: string, locale: string): string { + try { + return new Intl.DisplayNames([locale], { type: 'region' }).of(code) ?? code + } catch { + return code + } +} + /** * "SCB hittar två företag som liknar Adobe Systems Software, vilket menar * du?" The picker for a party without an org number: the user chooses, @@ -33,8 +42,9 @@ export function ScbPickerDialog({ }) { const t = useTranslations('parties') const tCommon = useTranslations('common') + const locale = useLocale() const [query, setQuery] = useState('') - const [loaded, setLoaded] = useState<{ key: string; result: ScbSearchResult | null; failed: boolean } | null>(null) + const [loaded, setLoaded] = useState<{ key: string; result: RegistryCandidatesResult | null; failed: boolean } | null>(null) const [selected, setSelected] = useState(null) const key = `${partyId}:${query.trim()}` @@ -49,7 +59,7 @@ export function ScbPickerDialog({ try { const params = query.trim() ? `?q=${encodeURIComponent(query.trim())}` : '' const res = await fetch(`/api/parties/${partyId}/enrich/candidates${params}`, { signal: ctrl.signal }) - const json = (await res.json()) as { data?: ScbSearchResult } + const json = (await res.json()) as { data?: RegistryCandidatesResult } if (!cancelled) setLoaded({ key, result: res.ok ? (json.data ?? null) : null, failed: !res.ok }) } catch { if (!cancelled) setLoaded({ key, result: null, failed: true }) @@ -64,6 +74,10 @@ export function ScbPickerDialog({ const result = current?.result ?? null const candidates = result?.candidates ?? [] + const foreign = !query.trim() && result?.foreign ? result.foreign : null + const foreignPlace = foreign?.country ? ` (${regionName(foreign.country, locale)})` : '' + // Other readings of the voucher text, offered when the one used found nothing. + const alternates = result && candidates.length === 0 && !query.trim() ? result.queries.filter((q) => q !== result.query) : [] const chosen = candidates.find((c) => c.orgNumber === selected) ?? null return ( @@ -76,12 +90,25 @@ export function ScbPickerDialog({ ? result.truncated ? t('picker_too_many', { count: result.total, query: result.query }) : candidates.length === 0 - ? t('picker_none', { query: result.query }) + ? foreign + ? t('picker_foreign', { name: foreign.name, place: foreignPlace }) + : t('picker_none', { query: result.query }) : t('picker_found', { count: candidates.length, query: result.query }) : t('picker_body', { name: partyName })}
+ {foreign && candidates.length === 0 && !loading ?

{t('picker_foreign_hint')}

: null} + {alternates.length > 0 && !loading ? ( +
+ {t('picker_try_instead')} + {alternates.map((q) => ( + + ))} +
+ ) : null} { diff --git a/lib/parties/__tests__/name-extract.test.ts b/lib/parties/__tests__/name-extract.test.ts new file mode 100644 index 00000000..6ed7dbd2 --- /dev/null +++ b/lib/parties/__tests__/name-extract.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' +import { extractNameCandidates, extractVatNumbers } from '../name-extract' + +describe('extractNameCandidates', () => { + it('reads a Swedish legal person out of an assistant-written description', () => { + const c = extractNameCandidates('1511768101 · Visma Spcs AB, faktura 2025-10-02, programvarulicens/abonnemang') + expect(c[0]).toMatchObject({ name: 'Visma Spcs AB', legalForm: 'AB', foreign: false, source: 'legal_form' }) + }) + + it('finds the company after a bank memo head and keeps (publ)', () => { + const c = extractNameCandidates( + 'TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746, The Intelligence Company AB (publ). TIC Identity-abonnemang.', + ) + expect(c[0]).toMatchObject({ name: 'The Intelligence Company AB (publ)', legalForm: 'AB (publ)', foreign: false }) + expect(c.map((x) => x.name)).toContain('TIC identity') + }) + + it('marks foreign legal forms with their country and does not offer them to SCB', () => { + expect(extractNameCandidates('Utlägg Framer · Framer B.V. (NL), webbdesignverktyg. Säljaren debiterat svensk moms via OSS (NL VAT NL853695386B01 på fakturan).')[0]).toMatchObject({ + name: 'Framer B.V.', + legalForm: 'B.V.', + country: 'NL', + foreign: true, + }) + expect(extractNameCandidates('Polar website software Överföring via internet · Polar Software Inc. (USA) - utländsk moms 24,75 USD ej avdragsgill')[0]).toMatchObject({ + name: 'Polar Software Inc.', + country: 'US', + foreign: true, + }) + expect(extractNameCandidates('Hostinger utlägg · Hostinger International Ltd (CY). Faktura 17,49 USD inkl 3,50 USD cypriotisk/EU-moms.')[0]).toMatchObject({ + name: 'Hostinger International Ltd', + legalForm: 'Ltd', + country: 'CY', + foreign: true, + }) + expect(extractNameCandidates('Utlägg Anthropic · Anthropic PBC, 206,12 EUR inkl. 41,22 EUR VAT-Sweden 25% via OSS.')[0]).toMatchObject({ + name: 'Anthropic PBC', + country: 'US', + foreign: true, + }) + }) + + it('anchors on a country word when there is no legal form, and marks the head foreign too', () => { + const c = extractNameCandidates( + 'Claude Maj H Överföring via internet · Anthropic Ireland, faktura 22,5 EUR inkl 4,5 EUR VAT-Sweden 25% via OSS. Säljardebiterad moms ej avdragsgill.', + ) + expect(c[0]).toMatchObject({ name: 'Anthropic Ireland', country: 'IE', foreign: true, source: 'country' }) + expect(c[1]).toMatchObject({ name: 'Claude Maj H', country: 'IE', foreign: true, source: 'head' }) + }) + + it('drops lead words and counters before the name', () => { + expect(extractNameCandidates('Delbetalning till 2 Fortnox Aktiebolag, faktura 4711')[0]).toMatchObject({ name: 'Fortnox AB', foreign: false }) + expect(extractNameCandidates('Kundbet Acme Konsult AB')[0]).toMatchObject({ name: 'Acme Konsult AB', foreign: false }) + expect(extractNameCandidates('Rättelse: Leverantörsfaktura 18299, RosholmDell Advokatbyrå AB (ankomst 2)')[0]).toMatchObject({ + name: 'RosholmDell Advokatbyrå AB', + foreign: false, + }) + expect(extractNameCandidates('Rättelse: Google oktober · Google Cloud EMEA Limited (Irland, EU). Faktura 2025-09-30')[0]).toMatchObject({ + name: 'Google Cloud EMEA Ltd', + country: 'IE', + foreign: true, + }) + }) + + it('keeps a Swedish company Swedish even when the text mentions VAT-Sweden', () => { + const c = extractNameCandidates('Telia Sverige AB, faktura, VAT-Sweden 25%') + expect(c[0]).toMatchObject({ name: 'Telia Sverige AB', country: 'SE', foreign: false }) + }) + + it('falls back to the cleaned head for plain bank text', () => { + const c = extractNameCandidates('BEIJER BYGGMATERIAL 2089') + expect(c).toEqual([{ name: 'BEIJER BYGGMATERIAL', foreign: false, source: 'head' }]) + }) + + it('does not mistake uppercase words or SL for legal forms', () => { + expect(extractNameCandidates('SL biljett Stockholm').some((c) => c.source === 'legal_form')).toBe(false) + expect(extractNameCandidates('DELBETALNING KONTOR').some((c) => c.source === 'legal_form')).toBe(false) + }) +}) + +describe('extractVatNumbers', () => { + it('reads EU VAT numbers and ignores uppercase words', () => { + expect(extractVatNumbers('NL VAT NL853695386B01 på fakturan; rekommendera SE559538621901 hos leverantören')).toEqual([ + { vat: 'NL853695386B01', country: 'NL' }, + { vat: 'SE559538621901', country: 'SE' }, + ]) + expect(extractVatNumbers('DELBETALNING SEKRETESS')).toEqual([]) + }) +}) diff --git a/lib/parties/__tests__/registry-search.test.ts b/lib/parties/__tests__/registry-search.test.ts new file mode 100644 index 00000000..bf6a56d3 --- /dev/null +++ b/lib/parties/__tests__/registry-search.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { planRegistryQueries } from '../registry-search' + +describe('planRegistryQueries', () => { + it('asks for the Swedish legal person before the bank memo head', () => { + const plan = planRegistryQueries({ + legalName: null, + displayName: 'TIC identity', + voucherTexts: ['TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746, The Intelligence Company AB (publ). TIC Identity-abonnemang.'], + }) + expect(plan.queries).toEqual(['The Intelligence Company', 'TIC identity']) + expect(plan.foreign).toBeNull() + }) + + it('plans no SCB query for a foreign company and says which one it read', () => { + const plan = planRegistryQueries({ + legalName: null, + displayName: 'Framer B.V.', + voucherTexts: ['Utlägg Framer · Framer B.V. (NL), webbdesignverktyg.'], + }) + expect(plan.queries).toEqual([]) + expect(plan.foreign).toEqual({ name: 'Framer B.V.', legalForm: 'B.V.', country: 'NL' }) + }) + + it('reads the country anchor when the head is a card memo', () => { + const plan = planRegistryQueries({ + legalName: null, + displayName: 'Claude Maj H', + voucherTexts: ['Claude Maj H Överföring via internet · Anthropic Ireland, faktura 22,5 EUR inkl 4,5 EUR VAT-Sweden 25% via OSS.'], + }) + expect(plan.queries).toEqual([]) + expect(plan.foreign).toEqual({ name: 'Anthropic Ireland', country: 'IE' }) + }) + + it('uses the plain name when nothing points abroad, and caps at three queries', () => { + const plan = planRegistryQueries({ legalName: 'Adobe Systems Software', displayName: 'ADOBE SYSTEMS', voucherTexts: [] }) + expect(plan.queries).toEqual(['Adobe Systems Software', 'ADOBE SYSTEMS']) + expect(plan.foreign).toBeNull() + const many = planRegistryQueries({ + legalName: null, + displayName: 'Acme', + voucherTexts: ['Alfa AB · x', 'Beta AB · y', 'Gamma AB · z', 'Delta AB · w'], + }) + expect(many.queries).toHaveLength(3) + }) +}) diff --git a/lib/parties/__tests__/suggest.test.ts b/lib/parties/__tests__/suggest.test.ts index cc7bb8cc..f834eb38 100644 --- a/lib/parties/__tests__/suggest.test.ts +++ b/lib/parties/__tests__/suggest.test.ts @@ -74,7 +74,7 @@ describe('buildSuggestions', () => { expect(item.alias_keys).toEqual(['beijer byggmaterial']) expect(item.reason.attach).toBe('new') expect(item.reason.occurrences).toBe(3) - expect(item.facts.map((f) => f.field)).toEqual(['dominant_account', 'cadence_days']) + expect(item.facts.map((f) => f.field)).toEqual(['dominant_account', 'cadence_days', 'voucher_text']) expect(item.identities).toEqual([]) }) @@ -102,10 +102,22 @@ describe('buildSuggestions', () => { expect(item.identities).toEqual([ { scheme: 'bankgiro', value: '53170900', first_seen: '2026-01-10', last_seen: '2026-03-10', seen_count: 3 }, ]) - expect(item.facts.map((f) => f.field)).toEqual(['dominant_account', 'cadence_days', 'org_number', 'legal_name']) + expect(item.facts.map((f) => f.field)).toEqual(['dominant_account', 'cadence_days', 'org_number', 'legal_name', 'voucher_text']) expect(item.reason.org_number).toBe(ORG) }) + it('names the legal person in an assistant-written text and takes a foreign VAT number only on the expense side', () => { + const text = 'Utlägg Framer · Framer B.V. (NL), webbdesignverktyg. Säljaren debiterat svensk moms via OSS (NL VAT NL853695386B01 på fakturan).' + const expense = buildSuggestions({ observed: [observed({ key: 'utlägg framer', name: text, expense_sek: 500, revenue_sek: 0 })], evidence: [], existing: [] }).items[0]! + expect(expense.display_name).toBe('Framer B.V.') + expect(expense.vat_number).toBe('NL853695386B01') + expect(expense.facts.find((f) => f.field === 'country')).toMatchObject({ value: 'NL', source: 'ledger' }) + expect(expense.facts.find((f) => f.field === 'voucher_text')).toMatchObject({ value: [text], source: 'ledger' }) + const revenue = buildSuggestions({ observed: [observed({ key: 'framer intäkt', name: text, expense_sek: 0, revenue_sek: 500 })], evidence: [], existing: [] }).items[0]! + expect(revenue.vat_number).toBeUndefined() + expect(revenue.facts.some((f) => f.field === 'vat_number')).toBe(false) + }) + it('withholds the hard key and identities when a key mixes two org numbers', () => { const r = buildSuggestions({ observed: [observed({ key: 'vattenfall' })], diff --git a/lib/parties/name-extract.ts b/lib/parties/name-extract.ts new file mode 100644 index 00000000..d4d84b3e --- /dev/null +++ b/lib/parties/name-extract.ts @@ -0,0 +1,279 @@ +/** + * Parties: which company a voucher text is talking about. + * + * Descriptions written by people and by the assistant carry the counterpart + * somewhere inside a sentence: "1511768101 · Visma Spcs AB, faktura + * 2025-10-02", "TIC identity BG 0000005786439 Bg-bet. via internet · Faktura + * 20250746, The Intelligence Company AB (publ)", "Utlägg Framer · Framer B.V. + * (NL), webbdesignverktyg". The ledger key groups such vouchers; this module + * names them. It anchors on legal-form words (AB, Inc., B.V., GmbH, Oy, ...) + * and on country words, and reads EU VAT numbers out of the text. Nothing is + * generated: every candidate is a substring of the text, returned in the + * order worth trying against a register. A foreign legal form or country + * means SCB's register cannot hold the company, so the caller can say so + * instead of searching in vain. + */ +import { displayNameFromVoucherText } from './ledger-key' + +export interface NameCandidate { + /** The name as written, with its legal form when there is one. */ + name: string + /** Canonical legal form, e.g. 'AB', 'B.V.', 'Inc.'. */ + legalForm?: string + /** ISO 3166-1 alpha-2 when the form or the text says so. */ + country?: string + /** Not a Swedish legal person: SCB's register cannot hold it. */ + foreign: boolean + source: 'legal_form' | 'country' | 'head' +} + +export interface VatNumberHit { + vat: string + country: string +} + +interface FormSpec { + pattern: string + canonical: string + foreign: boolean + country?: string + /** Short uppercase tokens are matched as written; words are not. */ + caseSensitive: boolean +} + +// Order matters where one form contains another (Pte. Ltd. before Ltd, +// Oyj before Oy, ASA before AS, AB (publ) before AB). +const FORMS: FormSpec[] = [ + { pattern: 'AB\\s*\\(publ\\)', canonical: 'AB (publ)', foreign: false, caseSensitive: true }, + { pattern: 'Aktiebolag(?:et)?', canonical: 'AB', foreign: false, caseSensitive: false }, + { pattern: 'AB', canonical: 'AB', foreign: false, caseSensitive: true }, + { pattern: 'HB', canonical: 'HB', foreign: false, caseSensitive: true }, + { pattern: 'KB', canonical: 'KB', foreign: false, caseSensitive: true }, + { pattern: 'ekonomisk förening', canonical: 'ek. för.', foreign: false, caseSensitive: false }, + { pattern: 'ek\\.?\\s*för\\.?', canonical: 'ek. för.', foreign: false, caseSensitive: false }, + { pattern: 'Pte\\.?\\s*Ltd\\.?', canonical: 'Pte. Ltd.', foreign: true, country: 'SG', caseSensitive: false }, + { pattern: 'Pty\\.?\\s*Ltd\\.?', canonical: 'Pty Ltd', foreign: true, country: 'AU', caseSensitive: false }, + { pattern: 'Inc\\.?', canonical: 'Inc.', foreign: true, country: 'US', caseSensitive: true }, + { pattern: 'Incorporated', canonical: 'Inc.', foreign: true, country: 'US', caseSensitive: false }, + { pattern: 'Corp\\.?', canonical: 'Corp.', foreign: true, country: 'US', caseSensitive: true }, + { pattern: 'Corporation', canonical: 'Corp.', foreign: true, country: 'US', caseSensitive: false }, + { pattern: 'LLC|L\\.L\\.C\\.', canonical: 'LLC', foreign: true, country: 'US', caseSensitive: true }, + { pattern: 'PBC', canonical: 'PBC', foreign: true, country: 'US', caseSensitive: true }, + { pattern: 'Ltd\\.?', canonical: 'Ltd', foreign: true, caseSensitive: true }, + { pattern: 'Limited', canonical: 'Ltd', foreign: true, caseSensitive: false }, + { pattern: 'PLC|plc', canonical: 'PLC', foreign: true, country: 'GB', caseSensitive: true }, + { pattern: 'LLP', canonical: 'LLP', foreign: true, caseSensitive: true }, + { pattern: 'GmbH(?:\\s*&\\s*Co\\.?\\s*KG)?', canonical: 'GmbH', foreign: true, country: 'DE', caseSensitive: true }, + { pattern: 'e\\.V\\.', canonical: 'e.V.', foreign: true, country: 'DE', caseSensitive: true }, + { pattern: 'AG', canonical: 'AG', foreign: true, caseSensitive: true }, + { pattern: 'B\\.V\\.|BV', canonical: 'B.V.', foreign: true, country: 'NL', caseSensitive: true }, + { pattern: 'N\\.V\\.|NV', canonical: 'N.V.', foreign: true, country: 'NL', caseSensitive: true }, + { pattern: 'Oyj', canonical: 'Oyj', foreign: true, country: 'FI', caseSensitive: true }, + { pattern: 'Oy', canonical: 'Oy', foreign: true, country: 'FI', caseSensitive: true }, + { pattern: 'ApS', canonical: 'ApS', foreign: true, country: 'DK', caseSensitive: true }, + { pattern: 'A/S', canonical: 'A/S', foreign: true, country: 'DK', caseSensitive: true }, + { pattern: 'ASA', canonical: 'ASA', foreign: true, country: 'NO', caseSensitive: true }, + { pattern: 'AS', canonical: 'AS', foreign: true, caseSensitive: true }, + { pattern: 'S\\.A\\.S\\.|SAS', canonical: 'SAS', foreign: true, country: 'FR', caseSensitive: true }, + { pattern: 'SARL|S\\.à\\.?\\s?r\\.l\\.|Sàrl|Sarl', canonical: 'SARL', foreign: true, caseSensitive: true }, + { pattern: 'S\\.A\\.', canonical: 'S.A.', foreign: true, caseSensitive: true }, + { pattern: 'S\\.L\\.', canonical: 'S.L.', foreign: true, country: 'ES', caseSensitive: true }, + { pattern: 'S\\.r\\.l\\.|Srl', canonical: 'S.r.l.', foreign: true, country: 'IT', caseSensitive: true }, + { pattern: 'S\\.p\\.A\\.|SpA', canonical: 'S.p.A.', foreign: true, country: 'IT', caseSensitive: true }, + { pattern: 'Kft\\.?', canonical: 'Kft.', foreign: true, country: 'HU', caseSensitive: true }, + { pattern: 'Zrt\\.?', canonical: 'Zrt.', foreign: true, country: 'HU', caseSensitive: true }, + { pattern: 'Sp\\.?\\s*z\\s*o\\.?\\s*o\\.?', canonical: 'Sp. z o.o.', foreign: true, country: 'PL', caseSensitive: false }, + { pattern: 'UAB', canonical: 'UAB', foreign: true, country: 'LT', caseSensitive: true }, + { pattern: 'OÜ', canonical: 'OÜ', foreign: true, country: 'EE', caseSensitive: true }, + { pattern: 'SIA', canonical: 'SIA', foreign: true, country: 'LV', caseSensitive: true }, + { pattern: 's\\.r\\.o\\.', canonical: 's.r.o.', foreign: true, caseSensitive: false }, + { pattern: 'd\\.o\\.o\\.', canonical: 'd.o.o.', foreign: true, caseSensitive: false }, + { pattern: 'Lda\\.?', canonical: 'Lda', foreign: true, country: 'PT', caseSensitive: true }, +] + +const FORM_REGEXES = FORMS.map((f) => ({ + spec: f, + re: new RegExp(`(?:^|[\\s(])(${f.pattern})(?=$|[\\s.,;:)])`, f.caseSensitive ? 'u' : 'iu'), +})) + +// Country words as they appear in voucher text, Swedish and English. +const COUNTRY_WORDS: Array<[RegExp, string]> = [ + [/\b(?:Sverige|Sweden)\b/iu, 'SE'], + [/\b(?:Ireland|Irland)\b/iu, 'IE'], + [/\b(?:USA|U\.S\.A\.|United States)\b/iu, 'US'], + [/\b(?:UK|U\.K\.|United Kingdom|Storbritannien|England)\b/u, 'GB'], + [/\b(?:Nederländerna|Netherlands|Holland)\b/iu, 'NL'], + [/\b(?:Cypern|Cyprus)\b/iu, 'CY'], + [/\b(?:Tyskland|Germany|Deutschland)\b/iu, 'DE'], + [/\b(?:Finland)\b/iu, 'FI'], + [/\b(?:Danmark|Denmark)\b/iu, 'DK'], + [/\b(?:Norge|Norway)\b/iu, 'NO'], + [/\b(?:Frankrike|France)\b/iu, 'FR'], + [/\b(?:Spanien|Spain)\b/iu, 'ES'], + [/\b(?:Italien|Italy)\b/iu, 'IT'], + [/\b(?:Singapore)\b/iu, 'SG'], + [/\b(?:Estland|Estonia)\b/iu, 'EE'], + [/\b(?:Lettland|Latvia)\b/iu, 'LV'], + [/\b(?:Litauen|Lithuania)\b/iu, 'LT'], + [/\b(?:Polen|Poland)\b/iu, 'PL'], + [/\b(?:Schweiz|Switzerland)\b/iu, 'CH'], + [/\b(?:Österrike|Austria)\b/iu, 'AT'], + [/\b(?:Belgien|Belgium)\b/iu, 'BE'], + [/\b(?:Luxemburg|Luxembourg)\b/iu, 'LU'], + [/\b(?:Portugal)\b/iu, 'PT'], + [/\b(?:Tjeckien|Czechia|Czech Republic)\b/iu, 'CZ'], + [/\b(?:Ungern|Hungary)\b/iu, 'HU'], + [/\b(?:Kanada|Canada)\b/iu, 'CA'], + [/\b(?:Australien|Australia)\b/iu, 'AU'], + [/\b(?:Indien|India)\b/iu, 'IN'], + [/\b(?:Kina|China)\b/iu, 'CN'], + [/\b(?:Japan)\b/iu, 'JP'], +] + +// Two- or three-letter codes only inside parentheses: "(NL)", "(USA)". +const CODE_IN_PARENS = /\((?:[^()]*?,\s*)?([A-Z]{2,3})\)/u +const CODE_MAP: Record = { + USA: 'US', US: 'US', UK: 'GB', GB: 'GB', IE: 'IE', NL: 'NL', CY: 'CY', DE: 'DE', FI: 'FI', DK: 'DK', NO: 'NO', + FR: 'FR', ES: 'ES', IT: 'IT', SG: 'SG', EE: 'EE', LV: 'LV', LT: 'LT', PL: 'PL', CH: 'CH', AT: 'AT', BE: 'BE', + LU: 'LU', PT: 'PT', CZ: 'CZ', HU: 'HU', CA: 'CA', AU: 'AU', IN: 'IN', CN: 'CN', JP: 'JP', SE: 'SE', +} + +// Words that precede a name without being part of it. +const LEAD_WORDS = new Set([ + 'utlägg', 'faktura', 'fakturor', 'leverantörsfaktura', 'levfakt', 'levfkt', 'kundfaktura', 'kvitto', 'betalning', + 'kundbet', 'kundbetalning', 'kundinbetalning', 'levbet', 'leverantörsbetalning', 'utbet', 'inbet', 'betalt', 'betald', + 'delbetalning', 'delbet', 'inbetalning', 'utbetalning', 'till', 'från', 'av', 'för', 'hos', 'via', 'och', 'rättelse', + 'ankomst', 'ref', 'inköp', 'köp', 'abonnemang', 'prenumeration', 'månadsavgift', 'avgift', 'konsult', 'tjänst', + 'kortköp/uttag', 'kortköp', 'uttag', 'överföring', 'internet', 'bg-bet.', 'bg-bet', 'pg-bet.', 'autogiro', +]) + +const VAT_RE = /\b(AT|BE|BG|HR|CY|CZ|DK|EE|FI|FR|DE|EL|HU|IE|IT|LV|LT|LU|MT|NL|PL|PT|RO|SK|SI|ES|SE|GB|XI)\s?([0-9A-Z]{8,12})\b/gu + +function stripVatSweden(text: string): string { + // "VAT-Sweden 25%" is a tax line on foreign invoices, not a country. + return text.replace(/VAT\s?-?\s?Sweden/giu, ' ') +} + +function countryHint(text: string): string | undefined { + const cleaned = stripVatSweden(text) + const code = CODE_IN_PARENS.exec(cleaned)?.[1] + if (code && CODE_MAP[code]) return CODE_MAP[code] + for (const [re, country] of COUNTRY_WORDS) if (re.test(cleaned)) return country + return undefined +} + +/** EU-style VAT numbers written in the text, deduplicated, SE included. */ +export function extractVatNumbers(text: string): VatNumberHit[] { + const out = new Map() + for (const m of text.matchAll(VAT_RE)) { + const body = m[2]! + if ((body.match(/\d/g) ?? []).length < 7) continue + const vat = `${m[1]}${body}` + if (!out.has(vat)) out.set(vat, { vat, country: m[1]! }) + } + return [...out.values()] +} + +function splitSegments(text: string): string[] { + return text + .split(/\s*(?:·|,|;|:|\||\n|\s[-–—]\s)\s*/u) + .map((s) => s.trim()) + .filter(Boolean) +} + +function nameTokensBefore(before: string): string[] { + let tokens = before.trim().split(/\s+/u).filter(Boolean) + // A parenthesis closes whatever came before it: "(ankomst 2) Acme". + const lastParen = tokens.map((t) => t.includes(')')).lastIndexOf(true) + if (lastParen >= 0) tokens = tokens.slice(lastParen + 1) + while (tokens.length) { + const t = tokens[0]! + const bare = t.replace(/^[("'`]+|[)"'`]+$/gu, '') + if (!/\p{L}/u.test(bare) || LEAD_WORDS.has(bare.toLowerCase()) || /^\d{4}-\d{2}(-\d{2})?$/u.test(bare)) { + tokens.shift() + continue + } + break + } + return tokens.slice(-6).map((t) => t.replace(/^[("'`]+|[)"'`.]+$/gu, '')) +} + +function legalFormCandidate(segment: string, next: string | undefined, whole: string): NameCandidate | null { + let best: { index: number; length: number; spec: FormSpec } | null = null + for (const { spec, re } of FORM_REGEXES) { + const m = re.exec(segment) + if (!m) continue + const index = m.index + m[0].length - m[1]!.length + if (!best || index < best.index) best = { index, length: m[1]!.length, spec } + } + if (!best) return null + const tokens = nameTokensBefore(segment.slice(0, best.index)) + if (tokens.length === 0) return null + const after = `${segment.slice(best.index + best.length)} ${next ?? ''}` + const country = best.spec.country ?? countryHint(after) ?? countryHint(whole) + const foreign = best.spec.foreign + return { + name: `${tokens.join(' ')} ${best.spec.canonical}`, + legalForm: best.spec.canonical, + ...(country ? { country } : {}), + foreign, + source: 'legal_form', + } +} + +function countryCandidate(segment: string): NameCandidate | null { + for (const [re, country] of COUNTRY_WORDS) { + if (country === 'SE') continue + const m = re.exec(stripVatSweden(segment)) + if (!m) continue + const tokens = nameTokensBefore(segment.slice(0, m.index)) + if (tokens.length === 0 || tokens.length > 4) return null + return { name: `${tokens.join(' ')} ${m[0]}`, country, foreign: true, source: 'country' } + } + return null +} + +/** + * Name candidates in the order worth trying: Swedish legal persons first, + * then names anchored on a country word, then the cleaned head of the text. + * The head is marked foreign when the text as a whole points abroad. + */ +export function extractNameCandidates(text: string): NameCandidate[] { + const out: NameCandidate[] = [] + const seen = new Set() + const push = (c: NameCandidate | null) => { + if (!c) return + const k = c.name.toLowerCase() + if (seen.has(k)) return + seen.add(k) + out.push(c) + } + const segments = splitSegments(text) + const forms: NameCandidate[] = [] + const countries: NameCandidate[] = [] + segments.forEach((seg, i) => { + const f = legalFormCandidate(seg, segments[i + 1], text) + if (f) forms.push(f) + else { + const c = countryCandidate(seg) + if (c) countries.push(c) + } + }) + forms.filter((c) => !c.foreign).forEach(push) + forms.filter((c) => c.foreign).forEach(push) + countries.forEach(push) + + const head = displayNameFromVoucherText(text) + if (head.length >= 2) { + const textCountry = countryHint(text) + const foreignVat = extractVatNumbers(text).some((v) => v.country !== 'SE') + const foreign = out.some((c) => c.foreign) || (textCountry !== undefined && textCountry !== 'SE') || foreignVat + push({ + name: head, + ...(textCountry && textCountry !== 'SE' ? { country: textCountry } : {}), + foreign, + source: 'head', + }) + } + return out +} diff --git a/lib/parties/registry-search.ts b/lib/parties/registry-search.ts new file mode 100644 index 00000000..c3fdbc55 --- /dev/null +++ b/lib/parties/registry-search.ts @@ -0,0 +1,65 @@ +/** + * Parties: what to ask SCB for a party without an org number. + * + * The picker used to search on the whole display name, which for an + * assistant-written voucher is a sentence. This plans the search from the + * name candidates read out of the party's name and its voucher texts: + * Swedish legal persons first, the cleaned head last, and nothing at all + * when the best reading is a foreign company, which SCB cannot hold. + */ +import { extractNameCandidates, type NameCandidate } from './name-extract' +import { nameQuery, type ScbSearchResult } from './scb/client' + +export interface ForeignReading { + name: string + legalForm?: string + country?: string +} + +export interface RegistryQueryPlan { + /** Queries worth trying, best first. Empty when the party looks foreign. */ + queries: string[] + /** The best reading of the counterpart when it is not a Swedish legal person. */ + foreign: ForeignReading | null + candidates: NameCandidate[] +} + +export interface RegistryCandidatesResult extends ScbSearchResult { + /** Every query the server tried or would try, best first. */ + queries: string[] + foreign: ForeignReading | null +} + +export const MAX_REGISTRY_QUERIES = 3 + +export function planRegistryQueries(input: { legalName: string | null; displayName: string; voucherTexts: string[] }): RegistryQueryPlan { + const texts = [input.legalName, input.displayName, ...input.voucherTexts].filter((t): t is string => !!t && t.trim().length > 0) + const seen = new Set() + const candidates: NameCandidate[] = [] + for (const text of texts) { + for (const c of extractNameCandidates(text)) { + const k = c.name.toLowerCase() + if (seen.has(k)) continue + seen.add(k) + candidates.push(c) + } + } + const swedishForm = candidates.filter((c) => c.source === 'legal_form' && !c.foreign) + const foreignAnchored = candidates.filter((c) => c.foreign && c.source !== 'head') + const foreign = swedishForm.length === 0 && foreignAnchored.length > 0 ? foreignAnchored[0]! : null + const ordered = foreign + ? [] + : [...swedishForm, ...candidates.filter((c) => c.source !== 'legal_form' && !c.foreign), ...candidates.filter((c) => c.source === 'head')] + const queries: string[] = [] + for (const c of ordered) { + const q = nameQuery(c.name) + if (q.length < 2 || queries.includes(q)) continue + queries.push(q) + if (queries.length >= MAX_REGISTRY_QUERIES) break + } + return { + queries, + foreign: foreign ? { name: foreign.name, ...(foreign.legalForm ? { legalForm: foreign.legalForm } : {}), ...(foreign.country ? { country: foreign.country } : {}) } : null, + candidates, + } +} diff --git a/lib/parties/scb/client.ts b/lib/parties/scb/client.ts index a1dbfe3c..c19c4ed4 100644 --- a/lib/parties/scb/client.ts +++ b/lib/parties/scb/client.ts @@ -65,7 +65,7 @@ export function nameQuery(raw: string): string { .replace(/^(levfakt|levfkt|lev\.?fakt\.?|leverantörsfaktura från\s*\d*|leverantörsfaktura|levbet\.?|kundbet\.?|kundfaktura|faktura från|faktura|kvitto|utgift|inköp)\s+/i, '') .replace(/[(),]/g, ' ') .replace(/\b\d{1,6}\b/g, ' ') - .replace(/\s+(ab|aktiebolag|hb|kb|publ|\(publ\))\.?\s*$/i, '') + .replace(/(\s+(?:ab|aktiebolag|hb|kb|publ|\(publ\)))+\.?\s*$/i, '') .replace(/'/g, '') .replace(/\s+/g, ' ') .trim() diff --git a/lib/parties/suggest.ts b/lib/parties/suggest.ts index 1ced347f..72fc4ac7 100644 --- a/lib/parties/suggest.ts +++ b/lib/parties/suggest.ts @@ -18,6 +18,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { coreKey, displayNameFromVoucherText } from './ledger-key' +import { extractNameCandidates, extractVatNumbers } from './name-extract' import { getObservedParties, type ObservedParty } from './observed' export interface IdentityEvidence { @@ -105,12 +106,45 @@ export interface BuildResult { skipped: SuggestionSkip[] } -function pickName(observed: ObservedParty, evidence: LedgerKeyEvidence | undefined): { display: string; legal?: string } { +interface PickedName { + display: string + legal?: string + /** ISO 3166-1 alpha-2 read out of the voucher text, when it says. */ + country?: string + /** The text points abroad: foreign legal form, country word or VAT prefix. */ + foreign?: boolean +} + +/** The voucher texts under a key, most common first, at most three. */ +function voucherTexts(observed: ObservedParty): string[] { + const out: string[] = [] + for (const t of [observed.name, ...(observed.variants ?? [])]) { + const v = (t ?? '').trim() + if (v && !out.includes(v)) out.push(v) + if (out.length === 3) break + } + return out +} + +function pickName(observed: ObservedParty, evidence: LedgerKeyEvidence | undefined): PickedName { // A printed supplier name from a document beats the voucher text, which is // upper-cased, truncated and prefixed by whatever the source system did. const printed = evidence?.names[0]?.name if (printed && printed.length >= 2) return { display: printed, legal: printed } - return { display: displayNameFromVoucherText(observed.name || observed.key) } + // Assistant-written descriptions bury the company in a sentence; a legal + // form or a country word in the text names it better than the head does. + const candidates = voucherTexts(observed).flatMap(extractNameCandidates) + const anchored = + 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 } + const head = candidates.find((c) => c.source === 'head') + return { + display: displayNameFromVoucherText(observed.name || observed.key), + ...(head?.country ? { country: head.country } : {}), + ...(head?.foreign ? { foreign: true } : {}), + } } function identitiesFrom(evidence: LedgerKeyEvidence | undefined): SuggestionIdentity[] { @@ -197,11 +231,30 @@ export function buildSuggestions(input: { if (name.legal) { facts.push({ field: 'legal_name', value: name.legal, source: 'document', reference: { docs: ev?.names[0]?.n ?? 0 } }) } + // The texts themselves, so the registry picker can read a name out of + // them later without scanning the ledger again. + const texts = voucherTexts(o) + if (texts.length) { + facts.push({ field: 'voucher_text', value: texts, source: 'ledger', reference: { occurrences: o.occurrences } }) + } + if (name.country) { + facts.push({ field: 'country', value: name.country, source: 'ledger', reference: { occurrences: o.occurrences } }) + } + // A single foreign VAT number written in the text is the counterpart's + // (the company's own SE number is what people note next to it). Only on + // the expense side: a supplier's VAT number is informational, while a + // customer's steers reverse charge on outgoing invoices and must come + // from a document or the person, never from a text heuristic. + const textVats = [...new Set(texts.flatMap(extractVatNumbers).filter((v) => v.country !== 'SE').map((v) => v.vat))] + const textVat = textVats.length === 1 && o.expense_sek >= o.revenue_sek && o.expense_sek > 0 ? textVats[0] : undefined + if (textVat && !ev?.vat_numbers[0]?.vat) { + facts.push({ field: 'vat_number', value: textVat, source: 'ledger', reference: { occurrences: o.occurrences } }) + } // Identities only when the hard key is unambiguous: a key that mixes two // org numbers would otherwise attach one supplier's bankgiro to another. const identities = orgs.length > 1 ? [] : identitiesFrom(ev) - const vat = ev?.vat_numbers[0]?.vat + const vat = ev?.vat_numbers[0]?.vat ?? textVat items.push({ key: o.key, diff --git a/messages/en.json b/messages/en.json index 63e9f84b..600e9b1e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8434,6 +8434,9 @@ "promoted_enriching": "Fetching details from SCB for {count} contacts…", "promoted_enriched_title": "Details fetched from SCB for {done} of {total}", "promote_dialog_missing_org": "{missing} of {count} have no org number and get nothing from SCB; find them in the business register first if you want them complete.", + "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", "fact_trade_name": "Trade name", "open_dossier": "Open {name}", "attn_create": "Create suggestions", diff --git a/messages/sv.json b/messages/sv.json index be429e14..af94a61a 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -8434,6 +8434,9 @@ "promoted_enriching": "Hämtar uppgifter från SCB för {count} kontakter…", "promoted_enriched_title": "Uppgifter hämtade från SCB för {done} av {total}", "promote_dialog_missing_org": "{missing} av {count} saknar org.nr och får inga uppgifter från SCB; leta upp dem i företagsregistret först om du vill ha dem kompletta.", + "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å", "fact_trade_name": "Firma", "open_dossier": "Öppna {name}", "attn_create": "Skapa förslag",