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
@@ -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([])
})
})
@@ -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)
})
})
+14 -2
View File
@@ -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' })],
+279
View File
@@ -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<string, string> = {
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<string, VatNumberHit>()
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<string>()
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
}
+65
View File
@@ -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<string>()
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,
}
}
+1 -1
View File
@@ -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()
+56 -3
View File
@@ -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,