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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' })],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user