feat(parties): fill customer and supplier forms from the register on a valid org number (#2355)

The registry lookup was built for rows that already exist (the detail
page's "Hämta uppgifter" records facts on the row's party), so on the
create form people typed what SCB already knew. Org number now comes
first in both forms; a complete, check-digit valid number of a Swedish
legal person is looked up once and fills name, address, postal code,
city (and the VAT number where the form shows one) wherever nothing has
been typed. A typed value is never replaced; a corrected number replaces
only its own earlier fill. A personnummer never reaches the register
(client key and server gate), and an environment without SCB credentials
answers 503 once and the form stays quiet.

- GET /api/parties/registry?org_number=: read-only route over the same
  SCB client, credential gate and registrySummary reader as the enrich
  route; writes nothing.
- lib/parties/registry-form-fill.ts: registryLookupKey (one rule for
  what may be looked up) and registryFormFill (contactFill's untouched
  rule plus name and VAT number), pure and tested.
- components/parties/use-registry-autofill.ts + RegistryAutofillNote:
  debounced, once per distinct number, skips the number an edit dialog
  opened with, one muted line under the field.
- Adressrad 2 is now an input on both forms: the register's c/o goes on
  line 1 with the street on line 2, as on the row, and both edit dialogs
  already passed the column in.


Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

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-06 21:03:06 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 3b9daf2606
commit fcda4a75dd
11 changed files with 837 additions and 105 deletions
+10
View File
@@ -4248,6 +4248,16 @@ export const PartySearchRegistryQuerySchema = z.object({
q: z.string().max(120).optional(),
})
/**
* GET /api/parties/registry: the org number a customer or supplier form is
* being filled for. Shape, check digit and the legal-person rule are one
* function (registryLookupKey in lib/parties/registry-form-fill), so the
* form and the route cannot disagree about what may be looked up.
*/
export const PartyRegistryLookupQuerySchema = z.object({
org_number: z.string().trim().min(1).max(20),
})
export const PartyUndoMergeSchema = z.object({
decisionId: uuid,
})
@@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest'
import { describeFilledFields, registryFormFill, registryLookupKey, type RegistryFormFields, type RegistryLookupFound } from '../registry-form-fill'
import type { RegistrySummary } from '../registry-summary'
function summary(over: Partial<RegistrySummary> = {}): RegistrySummary {
return {
legal_name: 'WEBHALLEN SVERIGE AB',
legal_form: 'Aktiebolag',
status: { label: 'Verksamt', active: true },
warning: null,
registrations: { f_tax: true, vat: true, employer: true },
industry: null,
seat: 'Stockholm',
registered_at: null,
active_since: null,
active_until: null,
employees_band: null,
turnover: null,
workplaces: null,
contact: { email: null, phone: null, address: { co: null, street: 'Storgatan 1', postal_code: '111 22', city: 'Stockholm' } },
vat_number: 'SE556252915501',
fetched_at: '2026-09-06T10:00:00Z',
...over,
}
}
function found(over: Partial<RegistryLookupFound> = {}, summaryOver: Partial<RegistrySummary> = {}): RegistryLookupFound {
return { found: true, orgNumber: '5562529155', name: 'Webhallen Sverige AB', registry: summary(summaryOver), ...over }
}
const empty: RegistryFormFields = { name: '', email: '', phone: '', address_line1: '', address_line2: '', postal_code: '', city: '', vat_number: '' }
describe('registryLookupKey', () => {
it('returns the canonical ten digits for a Swedish legal person, however written', () => {
expect(registryLookupKey('5562529155')).toBe('5562529155')
expect(registryLookupKey('556252-9155')).toBe('5562529155')
expect(registryLookupKey('16 556252-9155')).toBe('5562529155')
expect(registryLookupKey('165562529155')).toBe('5562529155')
})
it('is null while the number is incomplete or its check digit is wrong', () => {
expect(registryLookupKey('')).toBeNull()
expect(registryLookupKey(null)).toBeNull()
expect(registryLookupKey('556252-915')).toBeNull()
expect(registryLookupKey('5562529156')).toBeNull()
expect(registryLookupKey('abc')).toBeNull()
})
it('never yields a key for a personnummer, even with a valid check digit', () => {
// 800101-1231 is Luhn-valid: the refusal is about shape, not the check digit.
expect(registryLookupKey('8001011231')).toBeNull()
expect(registryLookupKey('800101-1231')).toBeNull()
expect(registryLookupKey('198001011231')).toBeNull()
})
})
describe('registryFormFill', () => {
it('fills name, VAT number and address on an empty form', () => {
expect(registryFormFill(empty, found(), null)).toEqual({
name: 'Webhallen Sverige AB',
vat_number: 'SE556252915501',
address_line1: 'Storgatan 1',
address_line2: '',
postal_code: '111 22',
city: 'Stockholm',
})
})
it('never replaces a value the person typed', () => {
const typed = { ...empty, name: 'Webhallen (butiken)', vat_number: 'SE999999999901' }
const patch = registryFormFill(typed, found(), null)
expect(patch.name).toBeUndefined()
expect(patch.vat_number).toBeUndefined()
expect(patch.address_line1).toBe('Storgatan 1')
})
it('leaves the whole address alone when any part of it was typed', () => {
const patch = registryFormFill({ ...empty, city: 'Uppsala' }, found(), null)
expect(patch).toEqual({ name: 'Webhallen Sverige AB', vat_number: 'SE556252915501' })
})
it('replaces its own earlier fill when the number is corrected', () => {
const first = found()
const afterFirst: RegistryFormFields = { ...empty, ...registryFormFill(empty, first, null) } as RegistryFormFields
const second = found(
{ orgNumber: '5560125790', name: 'Beijer Byggmaterial AB' },
{ legal_name: 'BEIJER BYGGMATERIAL AB', vat_number: 'SE556012579001', contact: { email: null, phone: null, address: { co: null, street: 'Norra vägen 5', postal_code: '169 70', city: 'Solna' } } },
)
expect(registryFormFill(afterFirst, second, first)).toEqual({
name: 'Beijer Byggmaterial AB',
vat_number: 'SE556012579001',
address_line1: 'Norra vägen 5',
address_line2: '',
postal_code: '169 70',
city: 'Solna',
})
})
it('keeps a name the person changed after the first fill', () => {
const first = found()
const edited: RegistryFormFields = { ...empty, ...registryFormFill(empty, first, null), name: 'Webhallen' } as RegistryFormFields
const second = found({ orgNumber: '5560125790', name: 'Beijer Byggmaterial AB' }, { legal_name: 'BEIJER BYGGMATERIAL AB' })
expect(registryFormFill(edited, second, first).name).toBeUndefined()
})
it('puts a c/o on line 1 and the street on line 2, as on the row', () => {
const patch = registryFormFill(empty, found({}, { contact: { email: null, phone: null, address: { co: 'c/o Byrån AB', street: 'Box 12', postal_code: '111 22', city: 'Stockholm' } } }), null)
expect(patch.address_line1).toBe('c/o Byrån AB')
expect(patch.address_line2).toBe('Box 12')
})
it('fills e-mail and phone when the register has them', () => {
const patch = registryFormFill(empty, found({}, { contact: { email: 'info@webhallen.com', phone: '08-123 45 67', address: null } }), null)
expect(patch).toEqual({ name: 'Webhallen Sverige AB', vat_number: 'SE556252915501', email: 'info@webhallen.com', phone: '08-123 45 67' })
})
it('touches nothing when the form already holds what the register says', () => {
const filled: RegistryFormFields = { ...empty, ...registryFormFill(empty, found(), null) } as RegistryFormFields
expect(registryFormFill(filled, found(), null)).toEqual({})
})
it('does not fill a field the form does not show', () => {
const patch = registryFormFill(empty, found(), null, ['name', 'address_line1', 'address_line2', 'postal_code', 'city'])
expect(patch.vat_number).toBeUndefined()
expect(patch.name).toBe('Webhallen Sverige AB')
expect(patch.city).toBe('Stockholm')
})
it('does nothing without a legal name or VAT number in the register', () => {
expect(registryFormFill(empty, found({ name: '' }, { legal_name: null, vat_number: null, contact: { email: null, phone: null, address: null } }), null)).toEqual({})
})
})
describe('describeFilledFields', () => {
it('collapses the address columns into one item in a fixed order', () => {
expect(describeFilledFields(['city', 'vat_number', 'postal_code', 'name', 'address_line1'])).toEqual(['name', 'address', 'vat_number'])
expect(describeFilledFields(['phone', 'email'])).toEqual(['email', 'phone'])
expect(describeFilledFields([])).toEqual([])
})
})
+118
View File
@@ -0,0 +1,118 @@
/**
* Parties: filling a customer or supplier form from the register.
*
* The registry lookup was built for rows that already exist (the detail
* page's "Hämta uppgifter" records facts on the row's party), so on the
* create form people typed what SCB already knew. This is the form side:
* which numbers may be looked up at all, and which fields a found company
* fills. Pure: form values in, patch out. The hook in
* components/parties/use-registry-autofill.ts decides when to call.
*/
import { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape'
import { normalizeOrgNumber } from '@/lib/invariants/org-number'
import { isLegalPersonOrgNumber } from './scb/org-number'
import { contactFill, fromRegistry, type RegistrySummary } from './registry-summary'
export interface RegistryLookupFound {
found: true
/** Canonical ten digits. */
orgNumber: string
/** The register's legal name in display case ("Webhallen Sverige AB"). */
name: string
registry: RegistrySummary
}
export interface RegistryLookupMissing {
found: false
orgNumber: string
}
export type RegistryLookup = RegistryLookupFound | RegistryLookupMissing
/**
* The canonical ten digits when the typed value is a complete org number
* of a Swedish legal person with a valid check digit; null for anything
* else. A personnummer (a private person's, or a sole trader's org number)
* is never a key: it must not reach the register, and the server refuses
* it too (SCB_NOT_A_LEGAL_PERSON). Both checks are kept although a legal
* person's number can never have personnummer shape: they guard different
* things and each is cheap.
*/
export function registryLookupKey(orgNumber: string | null | undefined): string | null {
const canonical = normalizeOrgNumber(orgNumber)
if (!canonical) return null
if (!isLegalPersonOrgNumber(canonical) || looksLikeSwedishPersonalNumber(canonical)) return null
return canonical
}
export interface RegistryFormFields {
name: string
email: string
phone: string
address_line1: string
address_line2: string
postal_code: string
city: string
vat_number: string
}
export type RegistryFormField = keyof RegistryFormFields
export const REGISTRY_FORM_FIELDS: readonly RegistryFormField[] = ['name', 'email', 'phone', 'address_line1', 'address_line2', 'postal_code', 'city', 'vat_number']
/**
* Which fields to set after a lookup. A field is filled when it is empty,
* or when it still holds what the previous lookup put there (a corrected
* number replaces its own fill); a value the person typed is never
* replaced. Contact fields follow the row rule from registry-summary
* (`contactFill`): the address is one unit, c/o on line 1 and street on
* line 2. `fields` is what the form shows; nothing lands in a field the
* person cannot see.
*/
export function registryFormFill(
current: RegistryFormFields,
now: RegistryLookupFound,
before: RegistryLookupFound | null,
fields: readonly RegistryFormField[] = REGISTRY_FORM_FIELDS,
): Partial<RegistryFormFields> {
const out: Partial<RegistryFormFields> = {}
const untouched = (value: string, previous: string | null | undefined) => value.trim() === '' || fromRegistry(value, previous)
if (now.name && untouched(current.name, before?.name) && !fromRegistry(current.name, now.name)) out.name = now.name
const vat = now.registry.vat_number
if (vat && untouched(current.vat_number, before?.registry.vat_number) && !fromRegistry(current.vat_number, vat)) out.vat_number = vat
const contact = contactFill(
{
email: current.email,
phone: current.phone,
address_line1: current.address_line1,
address_line2: current.address_line2,
postal_code: current.postal_code,
city: current.city,
},
now.registry.contact,
before?.registry.contact ?? null,
)
for (const [key, value] of Object.entries(contact)) out[key as Exclude<RegistryFormField, 'name' | 'vat_number'>] = value ?? ''
const shown = new Set(fields)
for (const key of Object.keys(out) as RegistryFormField[]) if (!shown.has(key)) delete out[key]
return out
}
/**
* The filled fields as the note under the org number lists them: the four
* address columns collapse into one "adress", in a fixed order.
*/
export function describeFilledFields(filled: readonly string[]): Array<'name' | 'address' | 'email' | 'phone' | 'vat_number'> {
const set = new Set(filled)
const out: Array<'name' | 'address' | 'email' | 'phone' | 'vat_number'> = []
if (set.has('name')) out.push('name')
if (set.has('address_line1') || set.has('address_line2') || set.has('postal_code') || set.has('city')) out.push('address')
if (set.has('email')) out.push('email')
if (set.has('phone')) out.push('phone')
if (set.has('vat_number')) out.push('vat_number')
return out
}