feat(parties): company details on the supplier and customer pages, the registry name becomes the displayed name (#2306)

Founder test on a real company (2026-09-05): a supplier created from
"Webhallen Oktober · Dataskärmar till kontoret" kept that text as its
name, and the SCB facts fetched for the party were nowhere on the
supplier page.

- Företagsuppgifter on /suppliers/[id] and /customers/[id]: legal name,
  org number, VAT number, country, then the SCB facts under one source
  line, with "Hämta uppgifter" or "Hitta i företagsregistret" as the one
  action. The registry helpers move out of the dossier into
  RegistryFacts so the three surfaces share them.
- The enrich route makes the registry's legal name the displayed name
  of the party and of supplier and customer rows that still carry the
  party's old name; all-capitals names are set in title case
  (lib/parties/registry-name.ts). Names a person set stay.
- legacyLedgerKey: a party confirmed under the pre-2026-09-04 key keeps
  its vouchers, so a rebuild attaches the new key instead of offering
  the same company again.
- GET /api/parties/[id] reports whether SCB is configured.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-05 13:43:34 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 397a3b9bca
commit 04898c3178
16 changed files with 424 additions and 61 deletions
@@ -98,7 +98,7 @@ describe('POST /api/parties/[id]/enrich', () => {
})
it('records the facts with provenance and fills an empty legal name', async () => {
enqueue({ data: { id: PARTY, org_number: '5560125790', legal_name: null } })
enqueue({ data: { id: PARTY, display_name: 'Beijer Byggmaterial AB', org_number: '5560125790', legal_name: null } })
lookupByOrgNumber.mockResolvedValue({
found: true,
peOrgNr: '165560125790',
@@ -131,9 +131,39 @@ describe('POST /api/parties/[id]/enrich', () => {
})
})
describe('POST /api/parties/[id]/enrich, the registry name becomes the displayed name', () => {
it('renames a memo-named party and its supplier row to the registry name in title case, and reports it', async () => {
enqueue({ data: { id: PARTY, display_name: 'Webhallen Oktober', org_number: '5565588224', legal_name: null } })
lookupByOrgNumber.mockResolvedValue({ found: true, peOrgNr: '165565588224', row: {}, facts: [{ field: 'legal_name', value: 'WEBHALLEN SVERIGE AB' }], fetchedAt: '2026-09-05T10:00:00Z' })
enqueue({ data: { inserted: 1, superseded: 0, refreshed: 0 } })
enqueue({ data: null, count: 0 }) // no user-entered legal name
enqueue({ data: null }) // parties.update
enqueue({ data: null }) // suppliers.update
enqueue({ data: null }) // customers.update
const { status, body } = await parseJsonResponse<{ data: { renamedTo: string | null } }>(await call())
expect(status).toBe(200)
expect(body.data.renamedTo).toBe('Webhallen Sverige AB')
const updates = mockSupabase.from.mock.calls.map((c) => c[0])
expect(updates.filter((t) => t === 'parties')).toHaveLength(2)
expect(updates).toContain('suppliers')
expect(updates).toContain('customers')
})
it('keeps a display name that already is the registry name, spelling aside', async () => {
enqueue({ data: { id: PARTY, display_name: 'Visma Spcs AB', org_number: '5562529155', legal_name: null } })
lookupByOrgNumber.mockResolvedValue({ found: true, peOrgNr: '165562529155', row: {}, facts: [{ field: 'legal_name', value: 'VISMA SPCS AB' }], fetchedAt: '2026-09-05T10:00:00Z' })
enqueue({ data: { inserted: 1, superseded: 0, refreshed: 0 } })
enqueue({ data: null, count: 0 })
enqueue({ data: null }) // parties.update (legal_name only)
const { body } = await parseJsonResponse<{ data: { renamedTo: string | null } }>(await call())
expect(body.data.renamedTo).toBeNull()
expect(mockSupabase.from.mock.calls.map((c) => c[0])).not.toContain('suppliers')
})
})
describe('POST /api/parties/[id]/enrich, legal name survivorship', () => {
it('replaces a document-sourced legal name with the registry name, but never one a person entered', async () => {
enqueue({ data: { id: PARTY, org_number: '5560125790', legal_name: 'Beijer Bygg' } })
enqueue({ data: { id: PARTY, display_name: 'Beijer Bygg', org_number: '5560125790', legal_name: 'Beijer Bygg' } })
lookupByOrgNumber.mockResolvedValue({ found: true, peOrgNr: '165560125790', row: {}, facts: [{ field: 'legal_name', value: 'AKTIEBOLAGET VOLVO' }], fetchedAt: '2026-09-03T10:00:00Z' })
enqueue({ data: { inserted: 1, superseded: 0, refreshed: 0 } })
enqueue({ data: null, count: 1 }) // a user-entered legal name exists
+23 -5
View File
@@ -6,6 +6,7 @@ import { createScbClient } from '@/lib/parties/scb/client'
import { isScbConfigured, scbConfigFromEnv } from '@/lib/parties/scb/config'
import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number'
import { ScbApiError } from '@/lib/parties/scb/transport'
import { displayNameFromRegistry, sameName } from '@/lib/parties/registry-name'
/**
* POST /api/parties/[id]/enrich: fetch the party's registry facts from SCB
@@ -42,14 +43,14 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
const { data: party, error } = await supabase
.from('parties')
.select('id, org_number, legal_name, vat_number')
.select('id, display_name, org_number, legal_name, vat_number')
.eq('company_id', companyId)
.eq('id', id)
.is('merged_into', null)
.maybeSingle()
if (error) throw new Error(`parties lookup failed: ${error.message}`)
if (!party) return errorResponseFromCode('NOT_FOUND', log, { requestId })
const p = party as { id: string; org_number: string | null; legal_name: string | null; vat_number: string | null }
const p = party as { id: string; display_name: string; org_number: string | null; legal_name: string | null; vat_number: string | null }
if (chosen && chosen !== p.org_number) {
if (!isLegalPersonOrgNumber(chosen)) return errorResponseFromCode('SCB_NOT_A_LEGAL_PERSON', log, { requestId })
@@ -107,7 +108,8 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
// registry's legal name replaces one read from documents or none at all,
// but never one a person entered (a legal_name fact with source 'user').
const legal = lookup.facts.find((f) => f.field === 'legal_name')?.value
if (typeof legal === 'string' && legal && legal !== p.legal_name) {
let renamedTo: string | null = null
if (typeof legal === 'string' && legal) {
const { count } = await supabase
.from('party_facts')
.select('id', { count: 'exact', head: true })
@@ -117,7 +119,23 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('source', 'user')
.is('superseded_at', null)
if (!count) {
await supabase.from('parties').update({ legal_name: legal }).eq('company_id', companyId).eq('id', id)
// The register's name also becomes the name people see, on the party
// and on the supplier and customer rows that still carry the party's
// old name: "Webhallen Oktober" was a memo, "Webhallen Sverige AB" is
// the company. A name a person set on the row (different from the
// party's) is theirs and stays.
const display = displayNameFromRegistry(legal)
const update: Record<string, string> = {}
if (legal !== p.legal_name) update.legal_name = legal
if (!sameName(p.display_name, display)) {
update.display_name = display
renamedTo = display
}
if (Object.keys(update).length) await supabase.from('parties').update(update).eq('company_id', companyId).eq('id', id)
if (renamedTo) {
await supabase.from('suppliers').update({ name: renamedTo }).eq('company_id', companyId).eq('party_id', id).eq('name', p.display_name)
await supabase.from('customers').update({ name: renamedTo }).eq('company_id', companyId).eq('party_id', id).eq('name', p.display_name)
}
}
}
@@ -132,7 +150,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
const r = (summary ?? {}) as Partial<Record<'inserted' | 'superseded' | 'refreshed', number>>
return NextResponse.json({
data: { found: true, orgNumber: p.org_number, inserted: r.inserted ?? 0, superseded: r.superseded ?? 0, refreshed: r.refreshed ?? 0, facts: lookup.facts },
data: { found: true, orgNumber: p.org_number, inserted: r.inserted ?? 0, superseded: r.superseded ?? 0, refreshed: r.refreshed ?? 0, facts: lookup.facts, renamedTo },
})
},
{ requireWrite: true },
+2 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getDossier } from '@/lib/parties/register'
import { isScbConfigured } from '@/lib/parties/scb/config'
/** GET /api/parties/[id]: the dossier for one party (facts, identities, decisions, vouchers). */
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
@@ -11,6 +12,6 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
if (!/^[0-9a-f-]{36}$/i.test(id)) return errorResponseFromCode('NOT_FOUND', log, { requestId })
const dossier = await getDossier(supabase, companyId, id)
if (!dossier) return errorResponseFromCode('NOT_FOUND', log, { requestId })
return NextResponse.json({ data: dossier })
return NextResponse.json({ data: dossier, scbConfigured: isScbConfigured() })
},
)