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
+1
View File
@@ -1594,6 +1594,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-04] Connector-hop failures (timeout, error envelope, wire-contract mismatch) are transient in every sync path: the row keeps its status and the user message says no renewal is needed, same as AspspUnavailableError (#2202), and the cron now treats AspspUnavailableError the same way instead of parking it in 'error'. Why: on 2026-09-04 the Connect service answered a shape the client rejects and the cron flipped four canary companies to 'error' with SYNC_FAILED_MESSAGE, so users re-authorized consents that were fine. The Zod issues are logged (field paths) because a bare 'unexpected shape' left the failure undiagnosable. Rejected: a new 'degraded' connection status (one more state every filter and the probe would have to learn; the health probe already catches a dead session on the same run) and removing the canary companies from the env (hides the contract bug instead of exposing its field paths).
[2026-09-05] Fortnox VAT-inclusive invoices (VATIncluded: true) now map their rows net of VAT (lib/providers/fortnox/mapper.ts netOfVat, preferring TotalExcludingVAT / PriceExcludingVAT when the payload carries them), and the migrated-row completion pass refuses a row set whose net or VAT disagrees with the header the same payload established by more than 1 kr (rowsMismatch, reported, never stored). Why: the first production run of the completion pass (#2291) wrote 345 Profilio invoices whose rows summed to the gross with 25 % on top, beside a correct header; the mapper had always read row Total as net, and the pass's only cross-check was the invoice total, which the header satisfied. Rows that contradict their own header are worse than no rows: the invoice page shows both, and for an open invoice the booking engine sums the rows. Rejected: comparing against the stored header (it may itself be the pre-#1745 default) and a wider tolerance (öresavrundning is at most 0.50 kr; the real disagreements are kronor).
[2026-09-05] Fortnox header-level Freight and AdministrationFee become synthetic rows in the sales mapper (lib/providers/fortnox/mapper.ts headerChargeLines), free-text rows (no quantity, no amount) land as line_type 'text' and no longer count as a stated 0 % rate in the migration's VAT resolver. Why: Fortnox keeps both charges outside InvoiceRows while Total and TotalVAT include them, so the rows summed to less than the header by exactly the charge, and after #2302 the rows-versus-header check refused those invoices (Profilio 14 of 384); the *VAT fields are amounts, not rates, and the charge is gross when VATIncluded, all verified on live payloads (invoices 295 and 242) rather than the spec, whose endpoint answered 429 all day. Text rows with VAT 0 beside 25 % rows had made roughly half of the Loftux and Clearstoq registers "mixed" with a null header rate. Rejected: dropping the charge into the first priced row (it is its own line on the customer's invoice, often on 3520) and trusting Net for the header (Net excludes the charges; gross minus TotalVAT is the net the rows must reach).
[2026-09-05] Parties: the register's legal name becomes the displayed name of a party and of its supplier or customer row when those still carry the memo the pipeline read ("Webhallen Oktober" becomes "Webhallen Sverige AB"); an all-capitals registry name is shown in title case with legal forms kept, the fact keeps the register's spelling. A name a person typed on the row is never touched. And a party confirmed under the pre-2026-09-04 ledger key keeps its vouchers through legacyLedgerKey: without it every such party came back as a fresh suggestion after the key change.
[2026-09-05] Provider OAuth returns to the initiating validated app/brand origin through a two-minute provider_otc handoff. State rows and handoffs have disjoint consume predicates, handoff consumption also binds the destination origin, and both success and provider denial require the original user on hop 2. The token exchange keeps the original configured provider redirect URI. Staging prerequisites 20260831111519, 20260902090000 and 20260902100000 were replayed from the existing SQL files before 20260905094806; MCP-assigned history timestamps were reconciled to those repository versions. Production and the live amnas Fortnox connect remain pending deployment and specific production-write approval.
[2026-09-05] PR #2305 review: encrypt both OAuth handoff payload columns with AES-256-GCM using a purpose-scoped derivation of the existing server-only service-role secret, matching other extension credential storage. Authenticate the handoff token, consent, initiating user, destination origin and column as additional data; reject plaintext or unreadable payloads after atomic consume. This resolves the at-rest encryption finding without new configuration, dependencies, or edits to the already-applied migration.
[2026-09-05] PR #2305 cleanup review: expire provider_otc rows through a service-role cron every five minutes, including abandoned states and encrypted handoffs whose consents remain. Use the existing cron-auth wrapper and generated hosted/self-hosted schedules; the migration is already applied on staging and remains unchanged.
+5
View File
@@ -26,6 +26,7 @@ import { invoiceNumberDisplay } from '@/lib/invoices/display'
import { getCountryName } from '@/lib/vat/country-codes'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
import { PartyFactsSection } from '@/components/parties/PartyFactsSection'
const CUSTOMER_TYPE_KEY: Record<CustomerType, string> = {
individual: 'type_individual',
@@ -281,6 +282,10 @@ export default function CustomerDetailPage({
</DefRow>
</DetailSection>
{(customer as { party_id?: string | null }).party_id && customer.customer_type !== 'individual' ? (
<PartyFactsSection partyId={(customer as { party_id?: string | null }).party_id as string} canWrite={canWrite} onChanged={() => void fetchCustomer()} />
) : null}
<DetailSection kicker={t('section_business')}>
<DefRow label={t('def_customer_number')}>
{customer.customer_number || <DefEmpty />}
+5
View File
@@ -19,6 +19,7 @@ import Link from 'next/link'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import type { Supplier, SupplierType, CreateSupplierInput, SupplierInvoice } from '@/types'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
import { PartyFactsSection } from '@/components/parties/PartyFactsSection'
// Supplier invoices carry their own currency; "kr" is only correct for SEK.
function amountWithCurrency(amount: number, currency?: string | null): string {
@@ -263,6 +264,10 @@ export default function SupplierDetailPage() {
</div>
</div>
{(supplier as { party_id?: string | null }).party_id ? (
<PartyFactsSection partyId={(supplier as { party_id?: string | null }).party_id as string} canWrite={canWrite} onChanged={() => void fetchSupplier()} />
) : null}
<DetailSection kicker={t('contact_section_title')}>
<DefRow label={t('def_email')}>{supplier.email || <DefEmpty />}</DefRow>
<DefRow label={t('def_phone')}>{supplier.phone || <DefEmpty />}</DefRow>
@@ -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() })
},
)
+1 -52
View File
@@ -12,6 +12,7 @@ import { SlideOver, SlideOverBody, SlideOverContent, SlideOverHeader } from '@/c
import type { Dossier, PartyRole, RegisterPeriod } from '@/lib/parties/register'
import { formatCurrency, formatDate, formatOrgNumber } from '@/lib/utils'
import { AccountNub } from './AccountNub'
import { registryFacts, registryLabel, registryValue } from './RegistryFacts'
import { regionName } from './SuggestionQueue'
import { formatPaymentIdentity, rhythmLabel, roleLabel } from './format'
import type { MergeCandidate } from './MergeDialog'
@@ -20,58 +21,6 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">{children}</h2>
}
const REGISTRY_FIELDS = [
'f_tax',
'vat_registration',
'employer_registration',
'company_status',
'legal_form',
'bolagsverket_status',
'employees_band',
'turnover_band',
'industry',
'postal_address',
'seat',
'registered_at',
'active_since',
'active_until',
'phone',
'email',
'workplaces',
'trade_name',
] as const
/** Live registry facts, in the order the dossier shows them; the VAT number sits in its own row above. */
function registryFacts(facts: Dossier['facts']): Dossier['facts'] {
const scb = facts.filter((f) => f.source === 'registry_scb')
return REGISTRY_FIELDS.flatMap((field) => scb.filter((f) => f.field === field))
}
function registryLabel(t: (k: string) => string, field: string): string {
return REGISTRY_FIELDS.includes(field as (typeof REGISTRY_FIELDS)[number]) ? t(`fact_${field}`) : field
}
/** Coded facts show their label; address and seat compose; the rest print. */
function registryValue(value: unknown): React.ReactNode {
if (value === null || value === undefined) return '·'
if (typeof value === 'string' || typeof value === 'number') return String(value)
const v = value as Record<string, unknown>
if (typeof v.label === 'string') {
const label = typeof v.year === 'string' && v.year ? `${v.label} (${v.year})` : v.label
return v.warning ? <span className="text-warning">{label}</span> : label
}
if ('street' in v || 'city' in v) {
return [v.co, v.street, [v.postal_code, v.city].filter(Boolean).join(' ')].filter(Boolean).join(', ')
}
if ('municipality' in v || 'municipality_code' in v) {
const parts = [v.municipality ?? v.municipality_code, v.county ?? v.county_code].filter(Boolean) as string[]
// "Stockholm, Stockholm": the county adds nothing when it repeats the municipality.
return parts.filter((x, i) => i === 0 || x !== parts[0]).join(', ')
}
if ('code' in v) return String(v.code)
return JSON.stringify(v)
}
function Row({ label, value, note }: { label: string; value: React.ReactNode; note?: React.ReactNode }) {
return (
<tr>
+159
View File
@@ -0,0 +1,159 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { DetailSection, DefRow, DefEmpty } from '@/components/ui/detail-section'
import { useToast } from '@/components/ui/use-toast'
import type { Dossier } from '@/lib/parties/register'
import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number'
import type { ScbCandidate } from '@/lib/parties/scb/client'
import { formatDate, formatOrgNumber } from '@/lib/utils'
import { registryFacts, registryLabel, registryValue } from './RegistryFacts'
import { ScbPickerDialog } from './ScbPickerDialog'
import { regionName } from './SuggestionQueue'
/**
* "Företagsuppgifter" on a supplier or customer page: what the register
* knows about the company behind the row. Legal name, org number and VAT
* number first, then the facts SCB gave under one source line, and one
* action: fetch by org number when there is one, find the company in the
* register when there is not. The same facts the party dossier shows; this
* is where people look for them.
*/
export function PartyFactsSection({
partyId,
canWrite,
onChanged,
}: {
partyId: string
canWrite: boolean
/** The party was enriched or renamed; the owning row may have changed too. */
onChanged?: () => void
}) {
const t = useTranslations('parties')
const locale = useLocale()
const { toast } = useToast()
const [dossier, setDossier] = useState<Dossier | null>(null)
const [scbEnabled, setScbEnabled] = useState(false)
const [loaded, setLoaded] = useState(false)
const [busy, setBusy] = useState(false)
const [picker, setPicker] = useState(false)
const load = useCallback(async () => {
try {
const res = await fetch(`/api/parties/${partyId}`)
if (!res.ok) throw new Error(String(res.status))
const json = (await res.json()) as { data: Dossier | null; scbConfigured?: boolean }
setDossier(json.data)
setScbEnabled(!!json.scbConfigured)
} catch {
setDossier(null)
} finally {
setLoaded(true)
}
}, [partyId])
useEffect(() => {
void load()
}, [load])
async function fetchRegistry(orgNumber?: string) {
setBusy(true)
try {
const res = await fetch(`/api/parties/${partyId}/enrich`, {
method: 'POST',
headers: orgNumber ? { 'Content-Type': 'application/json' } : undefined,
body: orgNumber ? JSON.stringify({ orgNumber }) : undefined,
})
const json = (await res.json()) as {
data?: { found: boolean; orgNumber: string; inserted: number; superseded: number; refreshed: number; renamedTo?: string | null }
error?: { details?: { reason?: string; displayName?: string } }
}
if (!res.ok || !json.data) {
if (json.error?.details?.reason === 'org_number_taken') {
toast({ title: t('picker_taken_title', { name: json.error.details.displayName ?? '' }), description: t('picker_taken_description') })
return
}
toast({ title: t('registry_unavailable_title'), variant: 'destructive' })
return
}
setPicker(false)
if (!json.data.found) {
toast({ title: t('registry_not_found_title'), description: t('registry_not_found_description', { org: json.data.orgNumber }) })
return
}
toast({
title: json.data.renamedTo ? t('facts_renamed_title', { name: json.data.renamedTo }) : t('registry_fetched_title'),
description: t('registry_fetched_description', { inserted: json.data.inserted, superseded: json.data.superseded, refreshed: json.data.refreshed }),
})
await load()
onChanged?.()
} catch {
toast({ title: t('registry_unavailable_title'), variant: 'destructive' })
} finally {
setBusy(false)
}
}
if (!loaded || !dossier) return null
const p = dossier.party
const registry = registryFacts(dossier.facts)
const fetchedAt = dossier.facts.find((f) => f.source === 'registry_scb')?.fetchedAt ?? null
const registryVat = dossier.facts.find((f) => f.field === 'vat_number' && f.source === 'registry_scb')?.value
const countryRaw = dossier.facts.find((f) => f.field === 'country')?.value
const country = typeof countryRaw === 'string' && /^[A-Za-z]{2}$/.test(countryRaw) ? countryRaw.toUpperCase() : null
const canFetch = scbEnabled && canWrite && isLegalPersonOrgNumber(p.orgNumber)
const canFind = scbEnabled && canWrite && !p.orgNumber && p.kind !== 'person' && (!country || country === 'SE')
return (
<>
<DetailSection
kicker={t('facts_section_title')}
aside={
canFetch ? (
<Button type="button" size="sm" variant="outline" onClick={() => void fetchRegistry()} disabled={busy}>
{busy ? t('fetching_registry') : registry.length > 0 ? t('facts_refresh') : t('fetch_registry')}
</Button>
) : canFind ? (
<Button type="button" size="sm" variant="outline" onClick={() => setPicker(true)} disabled={busy}>
{t('pick_registry')}
</Button>
) : undefined
}
>
<DefRow label={t('fact_legal_name')}>{p.legalName ?? <DefEmpty />}</DefRow>
<DefRow label={t('fact_org')}>{p.orgNumber ? <span className="tabular-nums">{formatOrgNumber(p.orgNumber)}</span> : <DefEmpty />}</DefRow>
<DefRow label={t('fact_vat')}>{p.vatNumber ?? (registryVat ? String(registryVat) : <DefEmpty />)}</DefRow>
{country ? <DefRow label={t('fact_country')}>{regionName(country, locale)}</DefRow> : null}
{registry.map((f) => (
<DefRow key={f.id} label={f.field === 'postal_address' && !(f.value as { street?: string | null })?.street ? t('fact_postal_code_city') : registryLabel(t, f.field)}>
{registryValue(f.value)}
</DefRow>
))}
<p className="pt-2 text-xs text-muted-foreground">
{fetchedAt
? t('registry_group', { date: formatDate(fetchedAt) })
: country && country !== 'SE'
? t('facts_foreign', { country: regionName(country, locale) })
: p.orgNumber
? t('facts_none_org')
: t('facts_none')}
</p>
</DetailSection>
{picker ? (
<ScbPickerDialog
open
onOpenChange={(open) => (!open ? setPicker(false) : undefined)}
partyId={partyId}
partyName={p.legalName ?? p.displayName}
busy={busy}
onPick={async (c: ScbCandidate) => {
await fetchRegistry(c.orgNumber)
}}
/>
) : null}
</>
)
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Registry facts as the dossier and the supplier and customer pages show
* them: which SCB fields, in what order, with what label, and how a coded
* or composite value prints. One module so the three surfaces cannot drift.
*/
import type { ReactNode } from 'react'
import type { Dossier } from '@/lib/parties/register'
export const REGISTRY_FIELDS = [
'f_tax',
'vat_registration',
'employer_registration',
'company_status',
'legal_form',
'bolagsverket_status',
'employees_band',
'turnover_band',
'industry',
'postal_address',
'seat',
'registered_at',
'active_since',
'active_until',
'phone',
'email',
'workplaces',
'trade_name',
] as const
/** Live registry facts, in the order the dossier shows them; the VAT number sits in its own row above. */
export function registryFacts(facts: Dossier['facts']): Dossier['facts'] {
const scb = facts.filter((f) => f.source === 'registry_scb')
return REGISTRY_FIELDS.flatMap((field) => scb.filter((f) => f.field === field))
}
export function registryLabel(t: (k: string) => string, field: string): string {
return REGISTRY_FIELDS.includes(field as (typeof REGISTRY_FIELDS)[number]) ? t(`fact_${field}`) : field
}
/** Coded facts show their label; address and seat compose; the rest print. */
export function registryValue(value: unknown): ReactNode {
if (value === null || value === undefined) return '·'
if (typeof value === 'string' || typeof value === 'number') return String(value)
const v = value as Record<string, unknown>
if (typeof v.label === 'string') {
const label = typeof v.year === 'string' && v.year ? `${v.label} (${v.year})` : v.label
return v.warning ? <span className="text-warning">{label}</span> : label
}
if ('street' in v || 'city' in v) {
return [v.co, v.street, [v.postal_code, v.city].filter(Boolean).join(' ')].filter(Boolean).join(', ')
}
if ('municipality' in v || 'municipality_code' in v) {
const parts = [v.municipality ?? v.municipality_code, v.county ?? v.county_code].filter(Boolean) as string[]
// "Stockholm, Stockholm": the county adds nothing when it repeats the municipality.
return parts.filter((x, i) => i === 0 || x !== parts[0]).join(', ')
}
if ('code' in v) return String(v.code)
return JSON.stringify(v)
}
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import { displayNameFromRegistry, sameName } from '../registry-name'
describe('displayNameFromRegistry', () => {
it('sets an all-capitals registry name in title case and keeps legal forms and acronyms', () => {
expect(displayNameFromRegistry('WEBHALLEN SVERIGE AB')).toBe('Webhallen Sverige AB')
expect(displayNameFromRegistry('AKTIEBOLAGET VOLVO')).toBe('Aktiebolaget Volvo')
expect(displayNameFromRegistry('THE INTELLIGENCE COMPANY AB (PUBL)')).toBe('The Intelligence Company AB (publ)')
expect(displayNameFromRegistry('SEB KORT BANK AB')).toBe('SEB Kort Bank AB')
expect(displayNameFromRegistry('SVENSK-DANSKA BYGG HB')).toBe('Svensk-Danska Bygg HB')
expect(displayNameFromRegistry('FÖRENINGEN FÖR SVENSK MUSIK')).toBe('Föreningen för Svensk Musik')
})
it('leaves a name with lowercase letters exactly as written', () => {
expect(displayNameFromRegistry('The Intelligence Company AB (publ)')).toBe('The Intelligence Company AB (publ)')
expect(displayNameFromRegistry('Visma Spcs AB')).toBe('Visma Spcs AB')
expect(displayNameFromRegistry(' Framer B.V. ')).toBe('Framer B.V.')
})
})
describe('sameName', () => {
it('compares names case- and whitespace-insensitively and never matches empties', () => {
expect(sameName('Visma Spcs AB', 'VISMA SPCS AB')).toBe(true)
expect(sameName('Webhallen Oktober', 'WEBHALLEN SVERIGE AB')).toBe(false)
expect(sameName('', '')).toBe(false)
expect(sameName(null, null)).toBe(false)
})
})
+15
View File
@@ -157,6 +157,21 @@ describe('buildSuggestions', () => {
expect(memo.name_anchored).toBeUndefined()
})
it('attaches a new key to the party confirmed under the pre-2026-09-04 key for the same vouchers', () => {
const text = 'Webhallen Oktober · Dataskärmar till kontoret'
const confirmed: ExistingParty = {
id: 'p-web',
display_name: 'Webhallen Oktober · Dataskärmar till kontoret',
org_number: null,
alias_keys: ['webhallen oktober dataskärmar till kontoret'],
status: 'confirmed',
}
const item = buildSuggestions({ observed: [observed({ key: 'webhallen', name: text })], evidence: [], existing: [confirmed] }).items[0]!
expect(item.party_id).toBe('p-web')
expect(item.reason.attach).toBe('alias_key')
expect(item.alias_keys).toEqual(['webhallen'])
})
it('withholds the hard key and identities when a key mixes two org numbers', () => {
const r = buildSuggestions({
observed: [observed({ key: 'vattenfall' })],
+25
View File
@@ -98,3 +98,28 @@ export function displayNameFromVoucherText(raw: string): string {
.trim()
return cleaned.length >= 2 ? cleaned : raw.trim()
}
const LEGACY_AP_PREFIX = /^(levfakt|levfkt|leverantörsfaktura från|leverantörsfaktura|levbet|faktura|kvitto|utgift)\s+/
const LEGACY_LEADING_SUPPLIER_NUMBER = /^\d{1,5}\s+/
const LEGACY_TRAILING_SHORT_DIGITS = /(\s+\d{1,3})+$/
/**
* The ledger key as it was computed before 2026-09-04 (migration
* 20260904002000): the whole description normalised, with only the AP
* prefix and short numbers stripped. Parties confirmed under that key keep
* it as an alias, and the vouchers that produced it now map to the new key,
* so a rebuild would otherwise offer the same company again as a fresh
* suggestion. buildSuggestions asks this for every voucher text and attaches
* the new key to the party that already owns the old one.
*/
export function legacyLedgerKey(raw: string | null | undefined): string {
const k = normalizeCounterpartyName(raw ?? '')
if (!k) return ''
const stripped = k
.replace(LEGACY_AP_PREFIX, '')
.replace(LEGACY_LEADING_SUPPLIER_NUMBER, '')
.replace(LEGACY_TRAILING_SHORT_DIGITS, '')
.replace(/\s+/g, ' ')
.trim()
return stripped === '' ? k : stripped
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Parties: the name a register gives, as the register shows it.
*
* Bolagsverket registers many company names in capitals, and SCB returns
* them that way ("WEBHALLEN SVERIGE AB", "AKTIEBOLAGET VOLVO"). A supplier
* list full of capitals reads like shouting, so an all-capitals name is set
* in title case with the legal-form tokens kept as they are written. A name
* with any lowercase letter is a name someone typed deliberately and is left
* alone. legal_name and the fact keep the registry's own spelling; only the
* display name is set this way.
*/
const KEEP_UPPER = new Set(['AB', 'HB', 'KB', 'AS', 'ASA', 'OY', 'OYJ', 'BV', 'NV', 'SA', 'AG', 'LLC', 'PLC', 'KG', 'GMBH', 'USA', 'UK', 'EU', 'IKEA', 'SJ', 'SL', 'SEB', 'ICA', 'SCB', 'KPMG', 'PWC', 'EY', 'BDO', 'ABB', 'SKF', 'SSAB', 'SAS', 'NCC', 'JM', 'HSB', 'LRF', 'ATG'])
const KEEP_LOWER = new Set(['AV', 'OCH', 'I', 'FÖR', 'MED', 'PÅ', 'TILL', 'FRÅN', 'DE', 'DEL', 'VON', 'VAN', 'DER', 'DEN', 'OF', 'THE', 'AND'])
function isAllCaps(s: string): boolean {
return /\p{Lu}/u.test(s) && !/\p{Ll}/u.test(s)
}
function titleWord(word: string, first: boolean): string {
const upper = word.toUpperCase()
if (upper === '(PUBL)') return '(publ)'
if (KEEP_UPPER.has(upper)) return upper === 'GMBH' ? 'GmbH' : upper
if (!first && KEEP_LOWER.has(upper)) return word.toLowerCase()
// Hyphenated and apostrophe parts each get a capital: "SVENSK-DANSKA" -> "Svensk-Danska".
return word.toLowerCase().replace(/(^|[-'’/&(])(\p{L})/gu, (_, sep: string, ch: string) => sep + ch.toUpperCase())
}
/** The display form of a registry name: title case when the register wrote it in capitals. */
export function displayNameFromRegistry(legalName: string): string {
const trimmed = legalName.replace(/\s+/g, ' ').trim()
if (!trimmed || !isAllCaps(trimmed)) return trimmed
return trimmed
.split(' ')
.map((w, i) => titleWord(w, i === 0))
.join(' ')
}
/** Same company name, spelling and case aside. */
export function sameName(a: string | null | undefined, b: string | null | undefined): boolean {
const norm = (s: string | null | undefined) => (s ?? '').replace(/\s+/g, ' ').trim().toLowerCase()
return norm(a) !== '' && norm(a) === norm(b)
}
+14 -1
View File
@@ -18,7 +18,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { roundOre } from '@/lib/money'
import { coreKey, displayNameFromVoucherText } from './ledger-key'
import { coreKey, displayNameFromVoucherText, legacyLedgerKey } from './ledger-key'
import { extractNameCandidates, extractVatNumbers } from './name-extract'
import { getObservedParties, type ObservedParty } from './observed'
@@ -220,6 +220,19 @@ export function buildSuggestions(input: {
const org = orgs.length === 1 ? orgs[0]!.org : undefined
const name = pickName(o, ev)
let existing = (org && byOrg.get(org)) || byAlias.get(o.key) || undefined
if (!existing) {
// A party claimed under the pre-2026-09-04 key keeps its vouchers: the
// old key is still an alias on it, and the same texts now produce the
// new key. Without this, every confirmed party from before the key
// change would come back as a fresh suggestion.
for (const text of voucherTexts(o)) {
const legacy = byAlias.get(legacyLedgerKey(text))
if (legacy) {
existing = legacy
break
}
}
}
let attach: SuggestionReason['attach'] = existing ? (org && byOrg.get(org) === existing ? 'org_number' : 'alias_key') : 'new'
if (!existing && name.anchored) {
const byName = byLegalName.get(name.display.trim().toLowerCase())
+6
View File
@@ -8691,6 +8691,12 @@
"review_done_failed": "{failed} could not be saved.",
"review_empty": "No rows are missing an org number.",
"picker_ai_read": "Read from the voucher: {name}.",
"facts_section_title": "Company details",
"facts_refresh": "Refresh from SCB",
"facts_none": "Nothing from the company register yet. Find the company and the details are fetched.",
"facts_none_org": "Nothing from the company register yet. Fetch them by org number.",
"facts_foreign": "Foreign company ({country}), not in the SCB register. Name and VAT number come from the documents.",
"facts_renamed_title": "Details fetched, now called {name}",
"fact_trade_name": "Trade name",
"open_dossier": "Open {name}",
"attn_create": "Create suggestions",
+6
View File
@@ -8691,6 +8691,12 @@
"review_done_failed": "{failed} gick inte att spara.",
"review_empty": "Inga rader saknar org.nr.",
"picker_ai_read": "Läst ur verifikatet: {name}.",
"facts_section_title": "Företagsuppgifter",
"facts_refresh": "Uppdatera från SCB",
"facts_none": "Inga uppgifter från företagsregistret än. Leta upp företaget så hämtas de.",
"facts_none_org": "Inga uppgifter från företagsregistret än. Hämta dem med org.nr.",
"facts_foreign": "Utländskt bolag ({country}), finns inte i SCB:s register. Namn och momsnummer kommer från underlagen.",
"facts_renamed_title": "Uppgifter hämtade, heter nu {name}",
"fact_trade_name": "Firma",
"open_dossier": "Öppna {name}",
"attn_create": "Skapa förslag",