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:
@@ -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<typeof import('@/lib/parties/scb/client')>()),
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
+16
-4
@@ -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.",
|
||||
|
||||
+16
-4
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user