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:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
397a3b9bca
commit
04898c3178
@@ -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
|
||||
|
||||
@@ -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,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() })
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user