From 04898c317885acbf0b3f973eeceab735ce41c528 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sat, 5 Sep 2026 13:43:34 +0200 Subject: [PATCH] feat(parties): company details on the supplier and customer pages, the registry name becomes the displayed name (#2306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DECISIONS.md | 1 + app/(dashboard)/customers/[id]/page.tsx | 5 + app/(dashboard)/suppliers/[id]/page.tsx | 5 + .../[id]/enrich/__tests__/route.test.ts | 34 +++- app/api/parties/[id]/enrich/route.ts | 28 ++- app/api/parties/[id]/route.ts | 3 +- components/parties/PartyDossier.tsx | 53 +----- components/parties/PartyFactsSection.tsx | 159 ++++++++++++++++++ components/parties/RegistryFacts.tsx | 59 +++++++ lib/parties/__tests__/registry-name.test.ts | 28 +++ lib/parties/__tests__/suggest.test.ts | 15 ++ lib/parties/ledger-key.ts | 25 +++ lib/parties/registry-name.ts | 43 +++++ lib/parties/suggest.ts | 15 +- messages/en.json | 6 + messages/sv.json | 6 + 16 files changed, 424 insertions(+), 61 deletions(-) create mode 100644 components/parties/PartyFactsSection.tsx create mode 100644 components/parties/RegistryFacts.tsx create mode 100644 lib/parties/__tests__/registry-name.test.ts create mode 100644 lib/parties/registry-name.ts diff --git a/DECISIONS.md b/DECISIONS.md index 3e2748a0..4ee932b0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1594,6 +1594,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/customers/[id]/page.tsx b/app/(dashboard)/customers/[id]/page.tsx index 8f64be0c..d0a46574 100644 --- a/app/(dashboard)/customers/[id]/page.tsx +++ b/app/(dashboard)/customers/[id]/page.tsx @@ -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 = { individual: 'type_individual', @@ -281,6 +282,10 @@ export default function CustomerDetailPage({ + {(customer as { party_id?: string | null }).party_id && customer.customer_type !== 'individual' ? ( + void fetchCustomer()} /> + ) : null} + {customer.customer_number || } diff --git a/app/(dashboard)/suppliers/[id]/page.tsx b/app/(dashboard)/suppliers/[id]/page.tsx index 111e6777..c9d744f7 100644 --- a/app/(dashboard)/suppliers/[id]/page.tsx +++ b/app/(dashboard)/suppliers/[id]/page.tsx @@ -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() { + {(supplier as { party_id?: string | null }).party_id ? ( + void fetchSupplier()} /> + ) : null} + {supplier.email || } {supplier.phone || } diff --git a/app/api/parties/[id]/enrich/__tests__/route.test.ts b/app/api/parties/[id]/enrich/__tests__/route.test.ts index 040f53e8..d130ba95 100644 --- a/app/api/parties/[id]/enrich/__tests__/route.test.ts +++ b/app/api/parties/[id]/enrich/__tests__/route.test.ts @@ -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 diff --git a/app/api/parties/[id]/enrich/route.ts b/app/api/parties/[id]/enrich/route.ts index 8975b1c7..7ababf59 100644 --- a/app/api/parties/[id]/enrich/route.ts +++ b/app/api/parties/[id]/enrich/route.ts @@ -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 = {} + 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> 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 }, diff --git a/app/api/parties/[id]/route.ts b/app/api/parties/[id]/route.ts index 7028b20e..344a661e 100644 --- a/app/api/parties/[id]/route.ts +++ b/app/api/parties/[id]/route.ts @@ -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() }) }, ) diff --git a/components/parties/PartyDossier.tsx b/components/parties/PartyDossier.tsx index 90e91d7f..a494860b 100644 --- a/components/parties/PartyDossier.tsx +++ b/components/parties/PartyDossier.tsx @@ -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

{children}

} -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 - if (typeof v.label === 'string') { - const label = typeof v.year === 'string' && v.year ? `${v.label} (${v.year})` : v.label - return v.warning ? {label} : 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 ( diff --git a/components/parties/PartyFactsSection.tsx b/components/parties/PartyFactsSection.tsx new file mode 100644 index 00000000..70a6692d --- /dev/null +++ b/components/parties/PartyFactsSection.tsx @@ -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(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 ( + <> + void fetchRegistry()} disabled={busy}> + {busy ? t('fetching_registry') : registry.length > 0 ? t('facts_refresh') : t('fetch_registry')} + + ) : canFind ? ( + + ) : undefined + } + > + {p.legalName ?? } + {p.orgNumber ? {formatOrgNumber(p.orgNumber)} : } + {p.vatNumber ?? (registryVat ? String(registryVat) : )} + {country ? {regionName(country, locale)} : null} + {registry.map((f) => ( + + {registryValue(f.value)} + + ))} +

+ {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')} +

+
+ + {picker ? ( + (!open ? setPicker(false) : undefined)} + partyId={partyId} + partyName={p.legalName ?? p.displayName} + busy={busy} + onPick={async (c: ScbCandidate) => { + await fetchRegistry(c.orgNumber) + }} + /> + ) : null} + + ) +} diff --git a/components/parties/RegistryFacts.tsx b/components/parties/RegistryFacts.tsx new file mode 100644 index 00000000..b885d1b4 --- /dev/null +++ b/components/parties/RegistryFacts.tsx @@ -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 + if (typeof v.label === 'string') { + const label = typeof v.year === 'string' && v.year ? `${v.label} (${v.year})` : v.label + return v.warning ? {label} : 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) +} diff --git a/lib/parties/__tests__/registry-name.test.ts b/lib/parties/__tests__/registry-name.test.ts new file mode 100644 index 00000000..50c1a8fb --- /dev/null +++ b/lib/parties/__tests__/registry-name.test.ts @@ -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) + }) +}) diff --git a/lib/parties/__tests__/suggest.test.ts b/lib/parties/__tests__/suggest.test.ts index 9a1342ff..7a74d57a 100644 --- a/lib/parties/__tests__/suggest.test.ts +++ b/lib/parties/__tests__/suggest.test.ts @@ -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' })], diff --git a/lib/parties/ledger-key.ts b/lib/parties/ledger-key.ts index 2a9715a6..0160fcf6 100644 --- a/lib/parties/ledger-key.ts +++ b/lib/parties/ledger-key.ts @@ -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 +} diff --git a/lib/parties/registry-name.ts b/lib/parties/registry-name.ts new file mode 100644 index 00000000..cc3cf74a --- /dev/null +++ b/lib/parties/registry-name.ts @@ -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) +} diff --git a/lib/parties/suggest.ts b/lib/parties/suggest.ts index 209d6808..1ed336f3 100644 --- a/lib/parties/suggest.ts +++ b/lib/parties/suggest.ts @@ -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()) diff --git a/messages/en.json b/messages/en.json index 6e92c816..30418141 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index cc3dca3b..ebda5e10 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",