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
+124 -74
View File
@@ -26,6 +26,9 @@ import {
isMaskedPersonalNumber,
} from '@/lib/customers/mask-personal-number'
import { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape'
import { registryFormFill, type RegistryFormField } from '@/lib/parties/registry-form-fill'
import { useRegistryAutofill } from '@/components/parties/use-registry-autofill'
import { RegistryAutofillNote } from '@/components/parties/RegistryAutofillNote'
import {
COUNTRY_CONSISTENCY_MESSAGES,
checkCountryConsistency,
@@ -34,6 +37,14 @@ import {
} from '@/lib/vat/country-codes'
import type { CreateCustomerInput } from '@/types'
/**
* What the register may fill on a Swedish company's org number. No VAT
* number: the form shows no VAT field for a Swedish customer, and nothing
* lands in a field the person cannot see (the row gets it from "Hämta
* uppgifter" on the customer page, with provenance).
*/
const REGISTRY_FIELDS: readonly RegistryFormField[] = ['name', 'email', 'phone', 'address_line1', 'address_line2', 'postal_code', 'city']
interface CustomerFormProps {
onSubmit: (data: CreateCustomerInput) => Promise<void>
isLoading: boolean
@@ -157,6 +168,8 @@ export default function CustomerForm({
handleSubmit,
watch,
control,
getValues,
setValue,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
@@ -170,6 +183,7 @@ export default function CustomerForm({
invoice_email_cc_addresses: initialData?.invoice_email_cc_addresses?.join('\n') ?? '',
invoice_email_bcc_addresses: initialData?.invoice_email_bcc_addresses?.join('\n') ?? '',
address_line1: initialData?.address_line1 || '',
address_line2: initialData?.address_line2 || '',
postal_code: initialData?.postal_code || '',
city: initialData?.city || '',
country: normalizeCountryCode(initialData?.country) ?? initialData?.country ?? 'SE',
@@ -185,6 +199,38 @@ export default function CustomerForm({
const customerType = watch('customer_type')
const vatNumber = watch('vat_number')
const orgNumber = watch('org_number')
// A complete org number of a Swedish company is looked up in SCB's
// register once, and the fields it knows are filled where nothing has
// been typed (issue #2218). Never for a privatperson's personnummer, and
// quiet when the environment has no SCB credentials.
const autofill = useRegistryAutofill({
orgNumber,
enabled: canWrite && customerType === 'swedish_business',
initialOrgNumber: initialData?.org_number,
apply: (now, before) => {
const v = getValues()
const patch = registryFormFill(
{
name: v.name ?? '',
email: v.email ?? '',
phone: v.phone ?? '',
address_line1: v.address_line1 ?? '',
address_line2: v.address_line2 ?? '',
postal_code: v.postal_code ?? '',
city: v.city ?? '',
vat_number: v.vat_number ?? '',
},
now,
before,
REGISTRY_FIELDS,
)
for (const [field, value] of Object.entries(patch)) {
setValue(field as RegistryFormField, value ?? '', { shouldDirty: true, shouldValidate: true })
}
return Object.keys(patch)
},
})
const countryValue = watch('country')
// A stored value the picker does not list (an unmapped legacy name, or a
// code outside the curated list) still has to be visible, or the field
@@ -307,6 +353,76 @@ export default function CustomerForm({
</p>
</div>
{/* Identification first: on a Swedish company's org number the register fills the rest */}
{customerType === 'individual' ? (
<div className="space-y-2">
<Label htmlFor="personal_number">{t('personal_number_label')}</Label>
<Input
id="personal_number"
placeholder={t('personal_number_placeholder')}
{...register('personal_number')}
/>
{errors.personal_number ? (
<p className="text-sm text-destructive">{errors.personal_number.message}</p>
) : personalNumberUnreadable ? (
<AttnLine>{t('personal_number_unreadable')}</AttnLine>
) : null}
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="org_number">{t('org_number_label')}</Label>
<Input
id="org_number"
placeholder={t('org_number_placeholder')}
{...register('org_number')}
/>
{errors.org_number ? (
<p className="text-sm text-destructive">{errors.org_number.message}</p>
) : (
<RegistryAutofillNote state={autofill} />
)}
</div>
{(customerType === 'eu_business' || customerType === 'non_eu_business') && (
<div className="space-y-2">
<Label htmlFor="vat_number">{t('vat_label')}</Label>
<div className="flex gap-2">
<Input
id="vat_number"
placeholder={customerType === 'eu_business' ? t('vat_placeholder_eu') : t('vat_placeholder_se')}
{...register('vat_number')}
className="flex-1"
/>
{customerType === 'eu_business' && (
<Button
type="button"
variant="outline"
onClick={handleValidateVat}
disabled={!vatNumber || isValidatingVat}
>
{isValidatingVat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : vatValidationResult?.valid ? (
<CheckCircle className="h-4 w-4 text-success" />
) : vatValidationResult?.valid === false ? (
<XCircle className="h-4 w-4 text-destructive" />
) : (
t('vat_verify')
)}
</Button>
)}
</div>
{customerType === 'eu_business' && (
<p className="text-xs text-muted-foreground">
{t('vat_hint_eu')}
</p>
)}
</div>
)}
</>
)}
{/* Name */}
<div className="space-y-2">
<Label htmlFor="name">{t('name_label')}</Label>
@@ -415,6 +531,14 @@ export default function CustomerForm({
{...register('address_line1')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="address_line2">{t('address_line2_label')}</Label>
<Input
id="address_line2"
placeholder={t('address_line2_placeholder')}
{...register('address_line2')}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">{t('postal_label')}</Label>
@@ -466,80 +590,6 @@ export default function CustomerForm({
</div>
</div>
{/* Identification: depends on customer type */}
{customerType === 'individual' ? (
<div className="space-y-4 pt-4 border-t">
<h3>{t('individual_section')}</h3>
<div className="space-y-2">
<Label htmlFor="personal_number">{t('personal_number_label')}</Label>
<Input
id="personal_number"
placeholder={t('personal_number_placeholder')}
{...register('personal_number')}
/>
{errors.personal_number ? (
<p className="text-sm text-destructive">{errors.personal_number.message}</p>
) : personalNumberUnreadable ? (
<AttnLine>{t('personal_number_unreadable')}</AttnLine>
) : null}
</div>
</div>
) : (
<div className="space-y-4 pt-4 border-t">
<h3>{t('business_section')}</h3>
<div className="space-y-2">
<Label htmlFor="org_number">{t('org_number_label')}</Label>
<Input
id="org_number"
placeholder={t('org_number_placeholder')}
{...register('org_number')}
/>
{errors.org_number && (
<p className="text-sm text-destructive">{errors.org_number.message}</p>
)}
</div>
{(customerType === 'eu_business' || customerType === 'non_eu_business') && (
<div className="space-y-2">
<Label htmlFor="vat_number">{t('vat_label')}</Label>
<div className="flex gap-2">
<Input
id="vat_number"
placeholder={customerType === 'eu_business' ? t('vat_placeholder_eu') : t('vat_placeholder_se')}
{...register('vat_number')}
className="flex-1"
/>
{customerType === 'eu_business' && (
<Button
type="button"
variant="outline"
onClick={handleValidateVat}
disabled={!vatNumber || isValidatingVat}
>
{isValidatingVat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : vatValidationResult?.valid ? (
<CheckCircle className="h-4 w-4 text-success" />
) : vatValidationResult?.valid === false ? (
<XCircle className="h-4 w-4 text-destructive" />
) : (
t('vat_verify')
)}
</Button>
)}
</div>
{customerType === 'eu_business' && (
<p className="text-xs text-muted-foreground">
{t('vat_hint_eu')}
</p>
)}
</div>
)}
</div>
)}
{/* Payment terms */}
<div className="space-y-2">
<Label htmlFor="payment_terms">{t('payment_terms_label')}</Label>
@@ -0,0 +1,53 @@
'use client'
import { useTranslations } from 'next-intl'
import { describeFilledFields } from '@/lib/parties/registry-form-fill'
import { listSv } from '@/lib/parties/registry-summary'
import { formatOrgNumber } from '@/lib/utils'
import type { RegistryAutofillState } from './use-registry-autofill'
/**
* The one line under the org number field that says what the register
* did: looking, "Webhallen Sverige AB · namn och adress från SCB", found
* but nothing to fill, or no such company. Nothing while idle, which is
* also what an environment without SCB shows.
*/
export function RegistryAutofillNote({ state }: { state: RegistryAutofillState }) {
const t = useTranslations('parties')
if (state.status === 'idle') return null
let text: string
switch (state.status) {
case 'looking':
text = t('autofill_looking')
break
case 'not_found':
text = t('autofill_not_found', { org: formatOrgNumber(state.orgNumber) })
break
case 'found':
text = t('autofill_found', { name: state.name })
break
case 'filled': {
const labels = describeFilledFields(state.fields).map((f) => {
switch (f) {
case 'name':
return t('autofill_field_name')
case 'address':
return t('facts_address_short')
case 'email':
return t('autofill_field_email')
case 'phone':
return t('autofill_field_phone')
case 'vat_number':
return t('autofill_field_vat')
}
})
text = t('autofill_filled', { name: state.name, fields: listSv(labels, t('facts_list_and')) })
break
}
}
return (
<p className="text-xs text-muted-foreground" aria-live="polite">
{text}
</p>
)
}
+104
View File
@@ -0,0 +1,104 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { registryLookupKey, type RegistryLookup, type RegistryLookupFound } from '@/lib/parties/registry-form-fill'
export type RegistryAutofillState =
| { status: 'idle' }
| { status: 'looking' }
/** A company was found and these form fields were set from it. */
| { status: 'filled'; name: string; fields: string[] }
/** A company was found but every field it knows was already typed. */
| { status: 'found'; name: string }
| { status: 'not_found'; orgNumber: string }
const DEBOUNCE_MS = 400
/**
* Looks a typed org number up in the register once it is complete and
* valid, and hands a found company to `apply`. One lookup per distinct
* number per form (answers are kept, so retyping a number costs nothing),
* none for the number the form opened with (an edit dialog must not fetch
* on open), none for a personnummer, and none at all once the environment
* has said it has no SCB credentials (503). A failed lookup leaves the form
* as it is: no toast, no spinner, the person keeps typing.
*/
export function useRegistryAutofill({
orgNumber,
enabled,
initialOrgNumber,
apply,
}: {
/** The org number field as typed. */
orgNumber: string | null | undefined
/** False while the row is not a Swedish company, or the person cannot write. */
enabled: boolean
/** The value the form opened with: only a change from it triggers a lookup. */
initialOrgNumber?: string | null
/** Sets form fields from a found company; returns the names of the fields it set. */
apply: (now: RegistryLookupFound, before: RegistryLookupFound | null) => string[]
}): RegistryAutofillState {
const [state, setState] = useState<RegistryAutofillState>({ status: 'idle' })
const applyRef = useRef(apply)
useEffect(() => {
applyRef.current = apply
}, [apply])
const answers = useRef(new Map<string, RegistryLookup>())
const unavailable = useRef(false)
const lastApplied = useRef<RegistryLookupFound | null>(null)
const opened = useRef(registryLookupKey(initialOrgNumber))
/** The key the current state describes; null while idle. */
const shown = useRef<string | null>(null)
const key = enabled ? registryLookupKey(orgNumber) : null
useEffect(() => {
if (key === shown.current) return
const quiet = () => {
shown.current = null
setState((s) => (s.status === 'idle' ? s : { status: 'idle' }))
}
if (!key || key === opened.current || unavailable.current) {
quiet()
return
}
const settle = (result: RegistryLookup) => {
shown.current = key
if (!result.found) {
setState({ status: 'not_found', orgNumber: result.orgNumber })
return
}
const fields = applyRef.current(result, lastApplied.current)
lastApplied.current = result
setState(fields.length > 0 ? { status: 'filled', name: result.name, fields } : { status: 'found', name: result.name })
}
const known = answers.current.get(key)
if (known) {
settle(known)
return
}
const ctrl = new AbortController()
const timer = setTimeout(async () => {
setState({ status: 'looking' })
try {
const res = await fetch(`/api/parties/registry?org_number=${encodeURIComponent(key)}`, { signal: ctrl.signal })
if (res.status === 503) unavailable.current = true
const json = res.ok ? ((await res.json()) as { data?: RegistryLookup }) : null
if (!json?.data) {
quiet()
return
}
answers.current.set(key, json.data)
settle(json.data)
} catch {
if (!ctrl.signal.aborted) quiet()
}
}, DEBOUNCE_MS)
return () => {
clearTimeout(timer)
ctrl.abort()
}
}, [key])
return state
}
+68 -23
View File
@@ -15,6 +15,9 @@ import { Loader2, Lock, X } from 'lucide-react'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getCountryOptions, normalizeCountryCode } from '@/lib/vat/country-codes'
import { registryFormFill, type RegistryFormField } from '@/lib/parties/registry-form-fill'
import { useRegistryAutofill } from '@/components/parties/use-registry-autofill'
import { RegistryAutofillNote } from '@/components/parties/RegistryAutofillNote'
import type { CreateSupplierInput } from '@/types'
interface SupplierFormProps {
@@ -87,6 +90,8 @@ export default function SupplierForm({
handleSubmit,
control,
watch,
getValues,
setValue,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
@@ -96,6 +101,7 @@ export default function SupplierForm({
email: initialData?.email || '',
phone: initialData?.phone || '',
address_line1: initialData?.address_line1 || '',
address_line2: initialData?.address_line2 || '',
postal_code: initialData?.postal_code || '',
city: initialData?.city || '',
country: normalizeCountryCode(initialData?.country) ?? initialData?.country ?? 'SE',
@@ -115,6 +121,39 @@ export default function SupplierForm({
},
})
const supplierType = watch('supplier_type')
const orgNumber = watch('org_number')
// A complete org number of a Swedish company is looked up in SCB's
// register once, and the fields it knows are filled where nothing has
// been typed (issue #2218). Quiet when the environment has no SCB
// credentials. The VAT number is among the fields here: the form shows it.
const autofill = useRegistryAutofill({
orgNumber,
enabled: canWrite && supplierType === 'swedish_business',
initialOrgNumber: initialData?.org_number,
apply: (now, before) => {
const v = getValues()
const patch = registryFormFill(
{
name: v.name ?? '',
email: v.email ?? '',
phone: v.phone ?? '',
address_line1: v.address_line1 ?? '',
address_line2: v.address_line2 ?? '',
postal_code: v.postal_code ?? '',
city: v.city ?? '',
vat_number: v.vat_number ?? '',
},
now,
before,
)
for (const [field, value] of Object.entries(patch)) {
setValue(field as RegistryFormField, value ?? '', { shouldDirty: true, shouldValidate: true })
}
return Object.keys(patch)
},
})
const countryValue = watch('country')
// A stored value the picker does not list (an unmapped legacy name, or a
// code outside the curated list) still has to be visible, or the field
@@ -151,6 +190,27 @@ export default function SupplierForm({
/>
</div>
{/* Identification first: on a Swedish company's org number the register fills the rest */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="org_number">{t('org_number_label')}</Label>
<Input
id="org_number"
placeholder={t('org_number_placeholder')}
{...register('org_number')}
/>
<RegistryAutofillNote state={autofill} />
</div>
<div className="space-y-2">
<Label htmlFor="vat_number">{t('vat_label')}</Label>
<Input
id="vat_number"
placeholder={t('vat_placeholder_se')}
{...register('vat_number')}
/>
</div>
</div>
{/* Name */}
<div className="space-y-2">
<Label htmlFor="name">{t('name_label')}</Label>
@@ -188,29 +248,6 @@ export default function SupplierForm({
</div>
</div>
{/* Business info */}
<div className="space-y-4 pt-4 border-t">
<h3>{t('business_section')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="org_number">{t('org_number_label')}</Label>
<Input
id="org_number"
placeholder={t('org_number_placeholder')}
{...register('org_number')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="vat_number">{t('vat_label')}</Label>
<Input
id="vat_number"
placeholder={t('vat_placeholder_se')}
{...register('vat_number')}
/>
</div>
</div>
</div>
{/* Address */}
<div className="space-y-4 pt-4 border-t">
<h3>{t('address_section')}</h3>
@@ -222,6 +259,14 @@ export default function SupplierForm({
{...register('address_line1')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="address_line2">{t('address_line2_label')}</Label>
<Input
id="address_line2"
placeholder={t('address_line2_placeholder')}
{...register('address_line2')}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">{t('postal_label')}</Label>