diff --git a/app/api/parties/registry/__tests__/route.test.ts b/app/api/parties/registry/__tests__/route.test.ts new file mode 100644 index 00000000..934e1c05 --- /dev/null +++ b/app/api/parties/registry/__tests__/route.test.ts @@ -0,0 +1,133 @@ +/** + * GET /api/parties/registry: the read-only SCB lookup behind the customer + * and supplier forms. No database traffic at all; the gates (credentials, + * legal person only) and the shape the form gets are what is checked. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +const { supabase: mockSupabase, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase) })) +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +const writeCheck = { ok: true } +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: () => Promise.resolve(writeCheck.ok ? { ok: true } : { ok: false, response: NextResponse.json({ error: 'Endast läsbehörighet.' }, { status: 403 }) }), +})) +const lookupByOrgNumber = vi.fn() +vi.mock('@/lib/parties/scb/client', async (importOriginal) => ({ + ...(await importOriginal()), + createScbClient: () => ({ lookupByOrgNumber }), +})) +const configured = { value: true } +vi.mock('@/lib/parties/scb/config', () => ({ + isScbConfigured: () => configured.value, + scbConfigFromEnv: () => ({ baseUrl: 'https://scb.test', pfx: Buffer.from('x'), passphrase: 'p', timeoutMs: 1 }), +})) + +import { GET } from '../route' + +const user = { id: 'user-1', email: 'test@test.se' } +const noParams = { params: Promise.resolve({}) } +const call = (orgNumber?: string) => + GET(createMockRequest('/api/parties/registry', orgNumber === undefined ? undefined : { searchParams: { org_number: orgNumber } }), noParams) + +const WEBHALLEN = { + found: true, + peOrgNr: '165562529155', + row: {}, + facts: [ + { field: 'legal_name', value: 'WEBHALLEN SVERIGE AB' }, + { field: 'vat_number', value: 'SE556252915501' }, + { field: 'company_status', value: { code: '1', label: 'Verksamt' } }, + { field: 'postal_address', value: { street: 'Storgatan 1', co: null, postal_code: '111 22', city: 'Stockholm' } }, + ], + fetchedAt: '2026-09-06T10:00:00Z', +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + configured.value = true + writeCheck.ok = true + mockSupabase.auth.getUser.mockResolvedValue({ data: { user } }) +}) + +describe('GET /api/parties/registry', () => { + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + expect((await parseJsonResponse(await call('5562529155'))).status).toBe(401) + expect(lookupByOrgNumber).not.toHaveBeenCalled() + }) + + it('returns 403 for a viewer: the lookup exists to create a row', async () => { + writeCheck.ok = false + expect((await parseJsonResponse(await call('5562529155'))).status).toBe(403) + expect(lookupByOrgNumber).not.toHaveBeenCalled() + }) + + it('returns 503 when SCB is not configured, before validating anything', async () => { + configured.value = false + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await call()) + expect(status).toBe(503) + expect(body.error.code).toBe('SCB_NOT_CONFIGURED') + expect(lookupByOrgNumber).not.toHaveBeenCalled() + }) + + it('returns 400 without an org number', async () => { + const { status, body } = await parseJsonResponse<{ type: string }>(await call()) + expect(status).toBe(400) + expect(body.type).toBe('validation_error') + expect(lookupByOrgNumber).not.toHaveBeenCalled() + }) + + it('refuses a personnummer with 400 and never calls SCB', async () => { + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await call('800101-1231')) + expect(status).toBe(400) + expect(body.error.code).toBe('SCB_NOT_A_LEGAL_PERSON') + expect(lookupByOrgNumber).not.toHaveBeenCalled() + }) + + it('refuses an incomplete number or a wrong check digit the same way', async () => { + expect((await parseJsonResponse(await call('556252-915'))).status).toBe(400) + expect((await parseJsonResponse(await call('5562529156'))).status).toBe(400) + expect(lookupByOrgNumber).not.toHaveBeenCalled() + }) + + it('maps an SCB failure to 502', async () => { + lookupByOrgNumber.mockRejectedValue(new Error('boom')) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await call('5562529155')) + expect(status).toBe(502) + expect(body.error.code).toBe('SCB_LOOKUP_FAILED') + }) + + it('reports a number the register does not hold', async () => { + lookupByOrgNumber.mockResolvedValue({ found: false, peOrgNr: '165562529155', row: null, facts: [], fetchedAt: '2026-09-06T10:00:00Z' }) + const { status, body } = await parseJsonResponse<{ data: { found: boolean; orgNumber: string } }>(await call('556252-9155')) + expect(status).toBe(200) + expect(body.data).toEqual({ found: false, orgNumber: '5562529155' }) + }) + + it('answers with the display name and the summary, touching no table', async () => { + lookupByOrgNumber.mockResolvedValue(WEBHALLEN) + const { status, body } = await parseJsonResponse<{ + data: { found: boolean; orgNumber: string; name: string; registry: { legal_name: string; vat_number: string; contact: { address: { street: string; city: string } } } } + }>(await call('16 556252-9155')) + expect(status).toBe(200) + expect(lookupByOrgNumber).toHaveBeenCalledWith('5562529155') + expect(body.data.found).toBe(true) + expect(body.data.orgNumber).toBe('5562529155') + expect(body.data.name).toBe('Webhallen Sverige AB') + expect(body.data.registry.legal_name).toBe('WEBHALLEN SVERIGE AB') + expect(body.data.registry.vat_number).toBe('SE556252915501') + expect(body.data.registry.contact.address).toMatchObject({ street: 'Storgatan 1', city: 'Stockholm' }) + expect(mockSupabase.from).not.toHaveBeenCalled() + expect(mockSupabase.rpc).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/parties/registry/route.ts b/app/api/parties/registry/route.ts new file mode 100644 index 00000000..f81ce79a --- /dev/null +++ b/app/api/parties/registry/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateQuery } from '@/lib/api/validate' +import { PartyRegistryLookupQuerySchema } from '@/lib/api/schemas' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { createScbClient } from '@/lib/parties/scb/client' +import { isScbConfigured, scbConfigFromEnv } from '@/lib/parties/scb/config' +import { ScbApiError } from '@/lib/parties/scb/transport' +import { displayNameFromRegistry } from '@/lib/parties/registry-name' +import { registryLookupKey, type RegistryLookup } from '@/lib/parties/registry-form-fill' +import { registrySummary } from '@/lib/parties/registry-summary' + +/** + * GET /api/parties/registry?org_number=: what SCB knows about a Swedish + * legal person, for the customer and supplier forms while the row does not + * exist yet. Reads only: no party, no facts, no row is written; provenance + * lands through POST /api/parties/[id]/enrich once the row has a party. + * Same client and same gates as that route: an environment without SCB + * credentials answers 503 before anything else so the form can go quiet, + * and a personnummer (a sole trader's org number) never reaches SCB. + * Writers only: the lookup exists to create a row, which viewers cannot. + */ +export const GET = withRouteContext( + 'parties.registry.lookup', + async (request, { log, requestId }) => { + if (!isScbConfigured()) return errorResponseFromCode('SCB_NOT_CONFIGURED', log, { requestId }) + const validated = validateQuery(request, PartyRegistryLookupQuerySchema, { log, operation: 'parties.registry.lookup' }) + if (!validated.success) return validated.response + + const orgNumber = registryLookupKey(validated.data.org_number) + if (!orgNumber) return errorResponseFromCode('SCB_NOT_A_LEGAL_PERSON', log, { requestId }) + + let lookup + try { + lookup = await createScbClient(scbConfigFromEnv()).lookupByOrgNumber(orgNumber) + } catch (err) { + log.warn('scb lookup failed', { orgNumber, status: err instanceof ScbApiError ? err.status : undefined, message: err instanceof Error ? err.message : String(err) }) + return errorResponseFromCode('SCB_LOOKUP_FAILED', log, { requestId }) + } + + const registry = lookup.found ? registrySummary(lookup.facts.map((f) => ({ ...f, source: 'registry_scb' as const, fetchedAt: lookup.fetchedAt }))) : null + if (!registry) { + const missing: RegistryLookup = { found: false, orgNumber } + return NextResponse.json({ data: missing }) + } + const data: RegistryLookup = { + found: true, + orgNumber, + name: registry.legal_name ? displayNameFromRegistry(registry.legal_name) : '', + registry, + } + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/components/customers/CustomerForm.tsx b/components/customers/CustomerForm.tsx index 5fff4596..b501bda5 100644 --- a/components/customers/CustomerForm.tsx +++ b/components/customers/CustomerForm.tsx @@ -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 isLoading: boolean @@ -157,6 +168,8 @@ export default function CustomerForm({ handleSubmit, watch, control, + getValues, + setValue, formState: { errors }, } = useForm({ 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({

+ {/* Identification first: on a Swedish company's org number the register fills the rest */} + {customerType === 'individual' ? ( +
+ + + {errors.personal_number ? ( +

{errors.personal_number.message}

+ ) : personalNumberUnreadable ? ( + {t('personal_number_unreadable')} + ) : null} +
+ ) : ( + <> +
+ + + {errors.org_number ? ( +

{errors.org_number.message}

+ ) : ( + + )} +
+ + {(customerType === 'eu_business' || customerType === 'non_eu_business') && ( +
+ +
+ + {customerType === 'eu_business' && ( + + )} +
+ {customerType === 'eu_business' && ( +

+ {t('vat_hint_eu')} +

+ )} +
+ )} + + )} + {/* Name */}
@@ -415,6 +531,14 @@ export default function CustomerForm({ {...register('address_line1')} />
+
+ + +
@@ -466,80 +590,6 @@ export default function CustomerForm({
- {/* Identification: depends on customer type */} - {customerType === 'individual' ? ( -
-

{t('individual_section')}

- -
- - - {errors.personal_number ? ( -

{errors.personal_number.message}

- ) : personalNumberUnreadable ? ( - {t('personal_number_unreadable')} - ) : null} -
-
- ) : ( -
-

{t('business_section')}

- -
- - - {errors.org_number && ( -

{errors.org_number.message}

- )} -
- - {(customerType === 'eu_business' || customerType === 'non_eu_business') && ( -
- -
- - {customerType === 'eu_business' && ( - - )} -
- {customerType === 'eu_business' && ( -

- {t('vat_hint_eu')} -

- )} -
- )} -
- )} - {/* Payment terms */}
diff --git a/components/parties/RegistryAutofillNote.tsx b/components/parties/RegistryAutofillNote.tsx new file mode 100644 index 00000000..64b47820 --- /dev/null +++ b/components/parties/RegistryAutofillNote.tsx @@ -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 ( +

+ {text} +

+ ) +} diff --git a/components/parties/use-registry-autofill.ts b/components/parties/use-registry-autofill.ts new file mode 100644 index 00000000..790dea42 --- /dev/null +++ b/components/parties/use-registry-autofill.ts @@ -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({ status: 'idle' }) + const applyRef = useRef(apply) + useEffect(() => { + applyRef.current = apply + }, [apply]) + const answers = useRef(new Map()) + const unavailable = useRef(false) + const lastApplied = useRef(null) + const opened = useRef(registryLookupKey(initialOrgNumber)) + /** The key the current state describes; null while idle. */ + const shown = useRef(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 +} diff --git a/components/suppliers/SupplierForm.tsx b/components/suppliers/SupplierForm.tsx index dbf0e865..e130497e 100644 --- a/components/suppliers/SupplierForm.tsx +++ b/components/suppliers/SupplierForm.tsx @@ -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({ 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({ />
+ {/* Identification first: on a Swedish company's org number the register fills the rest */} +
+
+ + + +
+
+ + +
+
+ {/* Name */}
@@ -188,29 +248,6 @@ export default function SupplierForm({
- {/* Business info */} -
-

{t('business_section')}

-
-
- - -
-
- - -
-
-
- {/* Address */}

{t('address_section')}

@@ -222,6 +259,14 @@ export default function SupplierForm({ {...register('address_line1')} />
+
+ + +
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 42dc7b88..943fd824 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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, }) diff --git a/lib/parties/__tests__/registry-form-fill.test.ts b/lib/parties/__tests__/registry-form-fill.test.ts new file mode 100644 index 00000000..dc6e7040 --- /dev/null +++ b/lib/parties/__tests__/registry-form-fill.test.ts @@ -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 { + 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 = {}, summaryOver: Partial = {}): 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([]) + }) +}) diff --git a/lib/parties/registry-form-fill.ts b/lib/parties/registry-form-fill.ts new file mode 100644 index 00000000..cb85b441 --- /dev/null +++ b/lib/parties/registry-form-fill.ts @@ -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 { + const out: Partial = {} + 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] = 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 +} diff --git a/messages/en.json b/messages/en.json index cd39abfa..8fbed5e7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1299,7 +1299,9 @@ "submit_save": "Save customer", "submit_saving": "Saving...", "viewer_disabled_tooltip": "You only have viewer access in this company", - "personal_number_unreadable": "The stored personal number cannot be read. Enter it again to replace it." + "personal_number_unreadable": "The stored personal number cannot be read. Enter it again to replace it.", + "address_line2_label": "Address line 2", + "address_line2_placeholder": "c/o" }, "form_supplier": { "name_label": "Name *", @@ -1345,7 +1347,9 @@ "notes_placeholder": "Internal notes about the supplier...", "submit_save": "Save supplier", "submit_saving": "Saving...", - "viewer_disabled_tooltip": "You only have viewer access in this company" + "viewer_disabled_tooltip": "You only have viewer access in this company", + "address_line2_label": "Address line 2", + "address_line2_placeholder": "c/o" }, "initial_setup": { "completed_verdict": "Your bookkeeping is up and running.", @@ -8823,8 +8827,16 @@ "open_dossier": "Open {name}", "attn_create": "Create suggestions", "auto_created_title": "{count} suggestions created from the books", - "auto_created_description": "Counterparts your vouchers name that are not in the register. Add them, or hide the ones that do not belong here." -}, + "auto_created_description": "Counterparts your vouchers name that are not in the register. Add them, or hide the ones that do not belong here.", + "autofill_looking": "Searching the SCB business register…", + "autofill_filled": "{name} · {fields} from SCB. Edit freely.", + "autofill_found": "{name} according to the SCB register. Nothing changed, the fields were already filled in.", + "autofill_not_found": "No company with org. no. {org} in the SCB register.", + "autofill_field_name": "name", + "autofill_field_email": "email", + "autofill_field_phone": "phone", + "autofill_field_vat": "VAT number" + }, "tx_expense_payout_match": { "title": "Book expense reimbursement", "description": "The transfer is booked against the person's liability account, the claims are marked as paid and the transaction is linked to the voucher.", diff --git a/messages/sv.json b/messages/sv.json index 798ccf55..125d8af4 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1299,7 +1299,9 @@ "submit_save": "Spara kund", "submit_saving": "Sparar...", "viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag", - "personal_number_unreadable": "Det sparade personnumret kan inte läsas. Skriv in det igen för att ersätta det." + "personal_number_unreadable": "Det sparade personnumret kan inte läsas. Skriv in det igen för att ersätta det.", + "address_line2_label": "Adressrad 2", + "address_line2_placeholder": "c/o" }, "form_supplier": { "name_label": "Namn *", @@ -1345,7 +1347,9 @@ "notes_placeholder": "Interna anteckningar om leverantören...", "submit_save": "Spara leverantör", "submit_saving": "Sparar...", - "viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag" + "viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag", + "address_line2_label": "Adressrad 2", + "address_line2_placeholder": "c/o" }, "initial_setup": { "completed_verdict": "Bokföringen är igång.", @@ -8823,8 +8827,16 @@ "open_dossier": "Öppna {name}", "attn_create": "Skapa förslag", "auto_created_title": "{count} förslag skapade från bokföringen", - "auto_created_description": "Motparter som dina verifikat namnger men som inte finns i registret. Lägg upp dem, eller dölj de som inte hör hemma här." -}, + "auto_created_description": "Motparter som dina verifikat namnger men som inte finns i registret. Lägg upp dem, eller dölj de som inte hör hemma här.", + "autofill_looking": "Söker i SCB:s företagsregister…", + "autofill_filled": "{name} · {fields} från SCB. Ändra fritt.", + "autofill_found": "{name} enligt SCB:s register. Inget ändrat, fälten var redan ifyllda.", + "autofill_not_found": "Inget företag med org.nr {org} i SCB:s register.", + "autofill_field_name": "namn", + "autofill_field_email": "e-post", + "autofill_field_phone": "telefon", + "autofill_field_vat": "momsnummer" + }, "tx_expense_payout_match": { "title": "Bokför återbetalning av utlägg", "description": "Överföringen bokförs mot personens skuldkonto, utläggen markeras som utbetalda och transaktionen kopplas till verifikatet.",