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:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
3b9daf2606
commit
fcda4a75dd
@@ -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 },
|
||||
)
|
||||
Reference in New Issue
Block a user