feat(parties): company details on the supplier and customer pages, the registry name becomes the displayed name (#2306)

Founder test on a real company (2026-09-05): a supplier created from
"Webhallen Oktober · Dataskärmar till kontoret" kept that text as its
name, and the SCB facts fetched for the party were nowhere on the
supplier page.

- Företagsuppgifter on /suppliers/[id] and /customers/[id]: legal name,
  org number, VAT number, country, then the SCB facts under one source
  line, with "Hämta uppgifter" or "Hitta i företagsregistret" as the one
  action. The registry helpers move out of the dossier into
  RegistryFacts so the three surfaces share them.
- The enrich route makes the registry's legal name the displayed name
  of the party and of supplier and customer rows that still carry the
  party's old name; all-capitals names are set in title case
  (lib/parties/registry-name.ts). Names a person set stay.
- legacyLedgerKey: a party confirmed under the pre-2026-09-04 key keeps
  its vouchers, so a rebuild attaches the new key instead of offering
  the same company again.
- GET /api/parties/[id] reports whether SCB is configured.

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-05 13:43:34 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 397a3b9bca
commit 04898c3178
16 changed files with 424 additions and 61 deletions
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import { displayNameFromRegistry, sameName } from '../registry-name'
describe('displayNameFromRegistry', () => {
it('sets an all-capitals registry name in title case and keeps legal forms and acronyms', () => {
expect(displayNameFromRegistry('WEBHALLEN SVERIGE AB')).toBe('Webhallen Sverige AB')
expect(displayNameFromRegistry('AKTIEBOLAGET VOLVO')).toBe('Aktiebolaget Volvo')
expect(displayNameFromRegistry('THE INTELLIGENCE COMPANY AB (PUBL)')).toBe('The Intelligence Company AB (publ)')
expect(displayNameFromRegistry('SEB KORT BANK AB')).toBe('SEB Kort Bank AB')
expect(displayNameFromRegistry('SVENSK-DANSKA BYGG HB')).toBe('Svensk-Danska Bygg HB')
expect(displayNameFromRegistry('FÖRENINGEN FÖR SVENSK MUSIK')).toBe('Föreningen för Svensk Musik')
})
it('leaves a name with lowercase letters exactly as written', () => {
expect(displayNameFromRegistry('The Intelligence Company AB (publ)')).toBe('The Intelligence Company AB (publ)')
expect(displayNameFromRegistry('Visma Spcs AB')).toBe('Visma Spcs AB')
expect(displayNameFromRegistry(' Framer B.V. ')).toBe('Framer B.V.')
})
})
describe('sameName', () => {
it('compares names case- and whitespace-insensitively and never matches empties', () => {
expect(sameName('Visma Spcs AB', 'VISMA SPCS AB')).toBe(true)
expect(sameName('Webhallen Oktober', 'WEBHALLEN SVERIGE AB')).toBe(false)
expect(sameName('', '')).toBe(false)
expect(sameName(null, null)).toBe(false)
})
})
+15
View File
@@ -157,6 +157,21 @@ describe('buildSuggestions', () => {
expect(memo.name_anchored).toBeUndefined()
})
it('attaches a new key to the party confirmed under the pre-2026-09-04 key for the same vouchers', () => {
const text = 'Webhallen Oktober · Dataskärmar till kontoret'
const confirmed: ExistingParty = {
id: 'p-web',
display_name: 'Webhallen Oktober · Dataskärmar till kontoret',
org_number: null,
alias_keys: ['webhallen oktober dataskärmar till kontoret'],
status: 'confirmed',
}
const item = buildSuggestions({ observed: [observed({ key: 'webhallen', name: text })], evidence: [], existing: [confirmed] }).items[0]!
expect(item.party_id).toBe('p-web')
expect(item.reason.attach).toBe('alias_key')
expect(item.alias_keys).toEqual(['webhallen'])
})
it('withholds the hard key and identities when a key mixes two org numbers', () => {
const r = buildSuggestions({
observed: [observed({ key: 'vattenfall' })],
+25
View File
@@ -98,3 +98,28 @@ export function displayNameFromVoucherText(raw: string): string {
.trim()
return cleaned.length >= 2 ? cleaned : raw.trim()
}
const LEGACY_AP_PREFIX = /^(levfakt|levfkt|leverantörsfaktura från|leverantörsfaktura|levbet|faktura|kvitto|utgift)\s+/
const LEGACY_LEADING_SUPPLIER_NUMBER = /^\d{1,5}\s+/
const LEGACY_TRAILING_SHORT_DIGITS = /(\s+\d{1,3})+$/
/**
* The ledger key as it was computed before 2026-09-04 (migration
* 20260904002000): the whole description normalised, with only the AP
* prefix and short numbers stripped. Parties confirmed under that key keep
* it as an alias, and the vouchers that produced it now map to the new key,
* so a rebuild would otherwise offer the same company again as a fresh
* suggestion. buildSuggestions asks this for every voucher text and attaches
* the new key to the party that already owns the old one.
*/
export function legacyLedgerKey(raw: string | null | undefined): string {
const k = normalizeCounterpartyName(raw ?? '')
if (!k) return ''
const stripped = k
.replace(LEGACY_AP_PREFIX, '')
.replace(LEGACY_LEADING_SUPPLIER_NUMBER, '')
.replace(LEGACY_TRAILING_SHORT_DIGITS, '')
.replace(/\s+/g, ' ')
.trim()
return stripped === '' ? k : stripped
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Parties: the name a register gives, as the register shows it.
*
* Bolagsverket registers many company names in capitals, and SCB returns
* them that way ("WEBHALLEN SVERIGE AB", "AKTIEBOLAGET VOLVO"). A supplier
* list full of capitals reads like shouting, so an all-capitals name is set
* in title case with the legal-form tokens kept as they are written. A name
* with any lowercase letter is a name someone typed deliberately and is left
* alone. legal_name and the fact keep the registry's own spelling; only the
* display name is set this way.
*/
const KEEP_UPPER = new Set(['AB', 'HB', 'KB', 'AS', 'ASA', 'OY', 'OYJ', 'BV', 'NV', 'SA', 'AG', 'LLC', 'PLC', 'KG', 'GMBH', 'USA', 'UK', 'EU', 'IKEA', 'SJ', 'SL', 'SEB', 'ICA', 'SCB', 'KPMG', 'PWC', 'EY', 'BDO', 'ABB', 'SKF', 'SSAB', 'SAS', 'NCC', 'JM', 'HSB', 'LRF', 'ATG'])
const KEEP_LOWER = new Set(['AV', 'OCH', 'I', 'FÖR', 'MED', 'PÅ', 'TILL', 'FRÅN', 'DE', 'DEL', 'VON', 'VAN', 'DER', 'DEN', 'OF', 'THE', 'AND'])
function isAllCaps(s: string): boolean {
return /\p{Lu}/u.test(s) && !/\p{Ll}/u.test(s)
}
function titleWord(word: string, first: boolean): string {
const upper = word.toUpperCase()
if (upper === '(PUBL)') return '(publ)'
if (KEEP_UPPER.has(upper)) return upper === 'GMBH' ? 'GmbH' : upper
if (!first && KEEP_LOWER.has(upper)) return word.toLowerCase()
// Hyphenated and apostrophe parts each get a capital: "SVENSK-DANSKA" -> "Svensk-Danska".
return word.toLowerCase().replace(/(^|[-'’/&(])(\p{L})/gu, (_, sep: string, ch: string) => sep + ch.toUpperCase())
}
/** The display form of a registry name: title case when the register wrote it in capitals. */
export function displayNameFromRegistry(legalName: string): string {
const trimmed = legalName.replace(/\s+/g, ' ').trim()
if (!trimmed || !isAllCaps(trimmed)) return trimmed
return trimmed
.split(' ')
.map((w, i) => titleWord(w, i === 0))
.join(' ')
}
/** Same company name, spelling and case aside. */
export function sameName(a: string | null | undefined, b: string | null | undefined): boolean {
const norm = (s: string | null | undefined) => (s ?? '').replace(/\s+/g, ' ').trim().toLowerCase()
return norm(a) !== '' && norm(a) === norm(b)
}
+14 -1
View File
@@ -18,7 +18,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { roundOre } from '@/lib/money'
import { coreKey, displayNameFromVoucherText } from './ledger-key'
import { coreKey, displayNameFromVoucherText, legacyLedgerKey } from './ledger-key'
import { extractNameCandidates, extractVatNumbers } from './name-extract'
import { getObservedParties, type ObservedParty } from './observed'
@@ -220,6 +220,19 @@ export function buildSuggestions(input: {
const org = orgs.length === 1 ? orgs[0]!.org : undefined
const name = pickName(o, ev)
let existing = (org && byOrg.get(org)) || byAlias.get(o.key) || undefined
if (!existing) {
// A party claimed under the pre-2026-09-04 key keeps its vouchers: the
// old key is still an alias on it, and the same texts now produce the
// new key. Without this, every confirmed party from before the key
// change would come back as a fresh suggestion.
for (const text of voucherTexts(o)) {
const legacy = byAlias.get(legacyLedgerKey(text))
if (legacy) {
existing = legacy
break
}
}
}
let attach: SuggestionReason['attach'] = existing ? (org && byOrg.get(org) === existing ? 'org_number' : 'alias_key') : 'new'
if (!existing && name.anchored) {
const byName = byLegalName.get(name.display.trim().toLowerCase())