From 22b98e0a3bdfad95c5678af4413e943af3e669e0 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 3 Sep 2026 20:16:59 +0200 Subject: [PATCH] feat(parties): fetch registry facts from SCB into the dossier, with a picker for parties without an org number (#2258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(parties): Kontakter register, suggestion queue, dossier and merge Phase 1's two surfaces on top of the parties substrate: - /parties page: one list with the five-way switch (Alla, Kunder, Leverantörer, Förslag, Bara i bokföringen), search, a 12-month/all period picker, and at most one attention line. Confirmed rows show roles as muted text, rhythm, underlag, dominant account and money. Observed rows are computed and never stored; a generic band keeps unattributed spend visible. - Suggestion queue: a reason per row, hard-key rows pre-ticked, bulk confirm behind one dialog, dismiss on hover, undo on the toast. - Dossier slide-over: Pengar, Bokföring, Vad Accounted vet (facts and identities with source and count), Underlag och verifikat, Historik. - Merge dialog with a visible, swappable survivor and undo. - API: GET /api/parties, GET /api/parties/[id], POST suggest, decide, decide/undo, merge, merge/undo (withRouteContext, Zod, 15 tests). - Migration 20260903090000: decide_parties snapshots the reason it clears; undo_party_decisions reverses confirm/dismiss within 30 days; decision kind 'undo'. - The pipeline runs after SIE import and provider migration (non-blocking) so a migrant's register is full on arrival. - Nav entry under Register; sv/en strings. Co-Authored-By: Claude Fable 5.1 * fix(parties): pass explicit interpolation values to next-intl next build's type check rejects a typed interface where the translator wants an index-signature record. Co-Authored-By: Claude Fable 5.1 * fix(parties): retry label on the load-failed state Co-Authored-By: Claude Fable 5.1 * fix(parties): hard keys for companies without org number, readable names, look-alikes at read time - get_ledger_key_evidence dropped every document for a company whose own org number is NULL (the self check compared against NULL). Replaced in 20260903100000 with a coalesced comparison; pg test covers it. - Display names come from the printed name on documents, otherwise from the voucher text with the AP/AR prefix and supplier number removed. - Look-alike parties (same core, or one core extending the other by whole words: Fortnox / Fortnox Finans) are detected when the register is read, never stored, and feed the Dubblett? chip and the merge dialog. - Queue shows Intäkt beside Kostnad; dossier hides zero money rows and formats bankgiro/plusgiro; merge dialog cancels with Avbryt; no synchronous setState inside effects. Co-Authored-By: Claude Fable 5.1 * feat(parties): link every new supplier and customer to a party on write The backfill covered the rows that existed on 2026-09-02; 108 rows created since had no party and never reached the register. A BEFORE INSERT/UPDATE trigger on customers and suppliers now calls ensure_party on every write path at once: find-or-create by org number inside the company, never by name; a private customer gets a kind=person party without any number; a nameless row stays unlinked; a foreign party id is refused with the same error as the composite foreign key; a link to a merged party follows the chain to the survivor; the clear that ON DELETE SET NULL performs is kept. ensure_party lets the trigger act for the row's owner (pg_trigger_depth() > 0); the RPC path is unchanged. The migration also links the rows created since the backfill. Co-Authored-By: Claude Fable 5.1 * fix(parties): dossier hides dismissed parties and follows merges to the survivor The register hid archived parties while the dossier still served them by id, and a merged party's dossier pointed at a dead row. Superagent P2 on #2206; three unit tests. Co-Authored-By: Claude Fable 5.1 * chore(parties): move the role-link migration past main's 20260903110000 Two files with one version would collide in schema_migrations. Co-Authored-By: Claude Fable 5.1 * feat(parties): confirm suggestions into Leverantörer and Kunder, no third noun Founder decision after the walkthrough: users know two words. The page becomes the queue 'Förslag från bokföringen' with 'Bara i bokföringen' beside it; the Kontakter nav entry and the Alla/Kunder/Leverantörer views go. Each suggestion shows what it becomes (Blir), read from the ledger side and changeable per row; confirming calls promote_parties, which creates the supplier and/or customer row from the party's facts, never a duplicate, and is undoable for 30 days through undo_party_promotions (the created rows are archived, the party returns to the queue). Leverantörer and Kunder carry the one attention line that leads here. The dossier offers Lägg upp som leverantör / som kund. Migration 20260903130000, 5 pg tests, route and unit tests updated. Co-Authored-By: Claude Fable 5.1 * fix(parties): write bankgiro and plusgiro the way the supplier form does Identities are stored as digits; suppliers carry 5317-0900. Co-Authored-By: Claude Fable 5.1 * feat(parties): fetch registry facts from SCB into the dossier SCB granted API access today (certificate + password, layouts Je and Ae). This adds the first registry enricher of phase 3: - lib/parties/scb: config from env (SCB_API_CERT_PFX_BASE64, SCB_API_CERT_PASSWORD), an mTLS transport on node:https, the mapping of every documented Je variable to a labelled fact, and a client whose wire format sits in one file because SCB replaces the API this month. Legal persons only: a sole trader's org number is a personnummer. - Migration 20260903150000: record_party_facts(company, user, party, source, facts, fetched_at) refreshes unchanged values, supersedes changed ones, never touches other sources. pg test. - POST /api/parties/[id]/enrich: 503 when not configured, 400 for a sole trader, 502 when SCB fails, fills an empty legal name. 7 tests. - Dossier: 'Hämta uppgifter' button (gated on configuration) and the registry rows with 'SCB · datum' as their source line. - scripts/scb/discover.ts prints the live variable list, code tables and one lookup so the request shape is checked against the real API. Co-Authored-By: Claude Fable 5.1 * fix(parties): SCB client on the live wire format, mapper on the real Je row Verified against the API on 2026-09-03: an identity lookup is one filter (Variabel 'OrgNr (10 siffror)', Operator ArLikaMed) without status keys, and the row carries ', kod' beside SCB's own text. The mapper now reads those columns, prefers SCB's text, and adds turnover band, seat names and Skatteverket registration. The AB Volvo row is the fixture. Co-Authored-By: Claude Fable 5.1 * fix(parties): registry legal name outranks the document one, never a person's Survivorship from the plan: user > registry > document. The dossier's legal-name row now carries 'SCB · datum' when the registry is the source. Co-Authored-By: Claude Fable 5.1 * fix(parties): VAT number from the moms flag, one primary action, one source line Founder review of the SCB dossier: - A Swedish company registered for moms has VAT number SE + org number + 01 by construction, so the registry's moms flag yields the number; it fills an empty vat_number on the party and shows in the Momsnr row instead of 'Saknas'. - The 'Registrerad hos Skatteverket' row said nothing (true for every legal person) and is gone. - Five buttons became one primary (the role the ledger suggests) and a menu with the rest; the per-row 'SCB · datum' notes became one group line 'Från SCB · hämtat datum'. - A postal-code-only address (large companies) is labelled as such. Co-Authored-By: Claude Fable 5.1 * fix(parties): do not repeat the county when it equals the municipality Co-Authored-By: Claude Fable 5.1 * feat(parties): SCB picker for parties without an org number 'Hitta i företagsregistret' in the dossier menu opens a picker: SCB is searched on the party's name (prefix first, contains as fallback, counts before rows, capped at 25, natural persons and estates excluded, active companies first). The user chooses; the org number is recorded as a fact with source 'user' and set on the party, then the normal fetch runs, so every later fetch is by number. A number another live party holds is refused with a pointer to it. One match is still shown, never auto-picked. The transport retries once on a dropped connection (seen live). Co-Authored-By: Claude Fable 5.1 * fix(parties): a picked org number shows in the queue's reason and counts as a hard key Co-Authored-By: Claude Fable 5.1 * fix(parties): SCB search tightened after a batch of real supplier names Twenty-five prod supplier names and twenty org numbers across every legal form went through the search and the lookup: - total is what the picker can offer, not SCB's raw count (Eismann counted one row and offered none, a natural person); - foreign legal forms stay in the query: they are part of the registered name and dropping them floods (Schmidt GmbH became 167 Schmidts); - a fusion or delning in progress is no longer a warning (Fortnox AB and Avanza Bank trade normally under 'Fusion pågår'); distress and disappearance codes still are. Co-Authored-By: Claude Fable 5.1 * chore(parties): move the four queue migrations past main's 20260903170000 Main merged 20260903120000_skattekonto_transactions_realtime_publication with the same version as the role-link trigger; the preview database refused the duplicate key. All four now sit after main's newest so the set applies in one ordered run on prod. Co-Authored-By: Claude Fable 5.1 * chore(parties): move record_party_facts after the queue migrations Co-Authored-By: Claude Fable 5.1 * chore(parties): move record_party_facts to a version after tonight's collisions Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 2 + app/(dashboard)/parties/page.tsx | 53 ++++ .../[id]/enrich/__tests__/route.test.ts | 204 ++++++++++++++ .../parties/[id]/enrich/candidates/route.ts | 44 +++ app/api/parties/[id]/enrich/route.ts | 136 +++++++++ components/parties/PartyDossier.tsx | 174 +++++++++--- components/parties/ScbPickerDialog.tsx | 141 ++++++++++ components/parties/SuggestionQueue.tsx | 2 +- components/parties/format.ts | 10 +- lib/api/schemas.ts | 13 + lib/errors/structured-errors.ts | 15 + lib/parties/register.ts | 5 +- .../scb/__tests__/fixtures/volvo-je.json | 101 +++++++ lib/parties/scb/__tests__/scb.test.ts | 218 ++++++++++++++ lib/parties/scb/client.ts | 147 ++++++++++ lib/parties/scb/config.ts | 41 +++ lib/parties/scb/map.ts | 265 ++++++++++++++++++ lib/parties/scb/org-number.ts | 22 ++ lib/parties/scb/transport.ts | 89 ++++++ messages/en.json | 46 +++ messages/sv.json | 46 +++ scripts/scb/discover.ts | 44 +++ .../20260904000200_record_party_facts.sql | 89 ++++++ tests/pg/record-party-facts.pg.test.ts | 76 +++++ 24 files changed, 1935 insertions(+), 48 deletions(-) create mode 100644 app/api/parties/[id]/enrich/__tests__/route.test.ts create mode 100644 app/api/parties/[id]/enrich/candidates/route.ts create mode 100644 app/api/parties/[id]/enrich/route.ts create mode 100644 components/parties/ScbPickerDialog.tsx create mode 100644 lib/parties/scb/__tests__/fixtures/volvo-je.json create mode 100644 lib/parties/scb/__tests__/scb.test.ts create mode 100644 lib/parties/scb/client.ts create mode 100644 lib/parties/scb/config.ts create mode 100644 lib/parties/scb/map.ts create mode 100644 lib/parties/scb/org-number.ts create mode 100644 lib/parties/scb/transport.ts create mode 100644 scripts/scb/discover.ts create mode 100644 supabase/migrations/20260904000200_record_party_facts.sql create mode 100644 tests/pg/record-party-facts.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 4c8d8778..c9ca0253 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1529,6 +1529,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-03] Skattekonto through Connect = the existing data proxy plus CONNECT_SKV_CANARY_COMPANIES, not a separate sync operation: the provider logic is two GETs and the dedup keys stay on the ledger; system (certificate) auth is still not brokered because hosted has no certificate configured, so every hosted skattekonto read is a user-token call the proxy already carries. [2026-09-03] Old-address social identities are unlinked by a BEFORE UPDATE trigger on auth.users (migration 20260903110000), not by the /auth/callback done path: the callback never runs for a completing click from a browser without a session, and admin-side changes bypass it entirely; the trigger covers every path and keeps the email identity, password and BankID intact. [2026-09-03] Kontakter is not a user-facing noun (founder, after the register walkthrough): the registers people see stay Leverantörer and Kunder, the new page is the queue 'Förslag från bokföringen' plus 'Bara i bokföringen', and confirming a suggestion creates the leverantör or kund row directly (promote_parties). A confirmed party with no role never appears in the UI. The party model underneath is unchanged +[2026-09-03] SCB registry lookups are made for juridiska personer only (org number with 20 or more in the month slot): a sole trader's org number is a personnummer, and sending it to SCB is personal-data processing with SCB as an independent controller; the plan keeps natural persons out of registry enrichment until the Art. 14 notice exists. The SokPaVar wire format sits in one file (lib/parties/scb/client.ts) because SCB replaces the API with an API-key one from September 2026 +[2026-09-03] SCB name search is a picker, never a lookup: the user chooses among SCB's matches and the chosen org number is recorded as a fact with source user before any fetch; one match is shown, not auto-picked, because a trade name is not an identity (Adobe Systems Software resolves to an Irish entity and a Swedish one) [2026-09-03] AGI redovisningsperiod = the payout month (agiReportingPeriod on payment_date), not salary_runs.period_*: Skatteverket files per the month the pay went out (kontantprincipen), so lön i efterskott (August work paid 25 September) is declared in September. The in-period payment-date guard (dashboard PATCH, lib/salary/update-run.ts, v1 PATCH, RunHeader min/max) is lifted rather than widened: its only stated reason was that the AGI keyed on period_*, and any residual month window would bite the next efterskott variant. Existing agi_declarations rows keep their stored period (no backfill): a declaration already filed under the earned month is a real-world correction with Skatteverket, not a re-key. New AGI_PERIOD_CONFLICT (409) refuses to overwrite a live run's declaration for the same payout month, since one month's AGI must cover every payment that month and the generator cannot merge runs. Issue #2191. [2026-09-03] The cursor:// deeplink is its own allowlist provider (cursor_deeplink) rendered "Din egen dator" and never "Verifierad", after the skeptic, CodeRabbit and Superagent all made the same point: a custom scheme can be claimed by any local app (RFC 8252 section 8.4), so it carries loopback trust, not vendor trust, and the consent page must not say otherwise; https://www.cursor.com/... keeps the verified label. Same pass fixed the consent-page CSP for custom schemes: new URL('cursor://...').origin is the string "null", so form-action became `'self' null` and Chromium would have blocked the post-consent 303 (correctness skeptic refutation); the header now uses the scheme-source (`cursor:`) when the origin is opaque. Not done: rejecting a missing code_challenge at /authorize. A code minted without one is unexchangeable (verifyPkce against an empty challenge is always false, now pinned by a test), so it is fail-closed; making it fail earlier is a separate change touching every client. [2026-09-03] The Lucide Building2 glyph is retired app-wide (founder, from the register walkthrough). Suppliers use Truck, companies and company-scoped things use Briefcase, banks use Landmark; the extension manifest icon name changed with it diff --git a/app/(dashboard)/parties/page.tsx b/app/(dashboard)/parties/page.tsx index 2e33608a..30beed2f 100644 --- a/app/(dashboard)/parties/page.tsx +++ b/app/(dashboard)/parties/page.tsx @@ -19,6 +19,8 @@ import { useToast } from '@/components/ui/use-toast' import { MergeDialog, type MergeCandidate } from '@/components/parties/MergeDialog' import { ObservedTable } from '@/components/parties/ObservedTable' import { PartyDossier } from '@/components/parties/PartyDossier' +import { ScbPickerDialog } from '@/components/parties/ScbPickerDialog' +import type { ScbCandidate } from '@/lib/parties/scb/client' import { SuggestionQueue } from '@/components/parties/SuggestionQueue' import { hasHardKey } from '@/components/parties/format' import { useCanWrite } from '@/lib/hooks/use-can-write' @@ -73,6 +75,8 @@ function SuggestionsPage() { const [roleOverrides, setRoleOverrides] = useState>({}) const [busy, setBusy] = useState(false) const [refreshing, setRefreshing] = useState(false) + const [fetchingRegistry, setFetchingRegistry] = useState(false) + const [picker, setPicker] = useState<{ partyId: string; name: string } | null>(null) const [confirmOpen, setConfirmOpen] = useState(false) const [dossierId, setDossierId] = useState(null) const [dossierReload, setDossierReload] = useState(0) @@ -119,6 +123,7 @@ function SuggestionsPage() { }, []) const counts = register?.counts + const scbEnabled = Boolean(register?.scbConfigured) const viewOptions = useMemo( () => VIEWS.map((v) => ({ @@ -212,6 +217,38 @@ function SuggestionsPage() { } } + async function fetchRegistry(id: string, orgNumber?: string) { + setFetchingRegistry(true) + try { + const res = await fetch(`/api/parties/${id}/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 }; error?: { code: string } } + if (!res.ok || !json.data) { + const details = (json as { error?: { details?: { reason?: string; displayName?: string } } }).error?.details + if (details?.reason === 'org_number_taken') { + toast({ title: t('picker_taken_title', { name: details.displayName ?? '' }), description: t('picker_taken_description') }) + return + } + toast({ title: t('registry_unavailable_title'), variant: 'destructive' }) + return + } + setPicker(null) + if (!json.data.found) { + toast({ title: t('registry_not_found_title'), description: t('registry_not_found_description', { org: json.data.orgNumber }) }) + return + } + toast({ title: t('registry_fetched_title'), description: t('registry_fetched_description', { inserted: json.data.inserted, superseded: json.data.superseded, refreshed: json.data.refreshed }) }) + setDossierReload((k) => k + 1) + } catch { + toast({ title: t('registry_unavailable_title'), variant: 'destructive' }) + } finally { + setFetchingRegistry(false) + } + } + async function runMerge(survivorId: string, mergedIds: string[]) { setBusy(true) try { @@ -387,8 +424,24 @@ function SuggestionsPage() { setDossierId(null) }} onMerge={(subject, suggested) => setMerge({ subject, suggested })} + onFetchRegistry={scbEnabled ? (id) => void fetchRegistry(id) : undefined} + onPickRegistry={scbEnabled ? (id, name) => setPicker({ partyId: id, name }) : undefined} + fetching={fetchingRegistry} /> + {picker ? ( + (!open ? setPicker(null) : undefined)} + partyId={picker.partyId} + partyName={picker.name} + busy={fetchingRegistry} + onPick={async (c: ScbCandidate) => { + await fetchRegistry(picker.partyId, c.orgNumber) + }} + /> + ) : null} + {merge ? ( ({ createClient: () => Promise.resolve(mockSupabase) })) +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }) })) +const lookupByOrgNumber = vi.fn() +const searchByName = vi.fn() +vi.mock('@/lib/parties/scb/client', () => ({ createScbClient: () => ({ lookupByOrgNumber, searchByName }) })) +const configured = { value: true } +vi.mock('@/lib/parties/scb/config', () => ({ + isScbConfigured: () => configured.value, + scbConfigFromEnv: () => ({ baseUrl: 'https://scb.test', pfx: Buffer.from('x'), passphrase: 'p', timeoutMs: 1 }), +})) + +import { POST } from '../route' +import { GET as candidatesGet } from '../candidates/route' + +const user = { id: 'user-1', email: 'test@test.se' } +const PARTY = '11111111-1111-4111-8111-111111111111' +const OTHER = '22222222-2222-4222-8222-222222222222' +const call = (id = PARTY) => POST(createMockRequest(`/api/parties/${id}/enrich`, { method: 'POST' }), { params: Promise.resolve({ id }) }) +const callWith = (body: unknown, id = PARTY) => { + const req = createMockRequest(`/api/parties/${id}/enrich`, { method: 'POST', body }) + return POST(req, { params: Promise.resolve({ id }) }) +} +const candidates = (q?: string, id = PARTY) => candidatesGet(createMockRequest(`/api/parties/${id}/enrich/candidates${q ? `?q=${encodeURIComponent(q)}` : ''}`), { params: Promise.resolve({ id }) }) + +beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + configured.value = true + mockSupabase.auth.getUser.mockResolvedValue({ data: { user } }) +}) + +describe('POST /api/parties/[id]/enrich', () => { + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + expect((await parseJsonResponse(await call())).status).toBe(401) + }) + + it('returns 503 when SCB is not configured, before touching the database', async () => { + configured.value = false + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await call()) + expect(status).toBe(503) + expect(body.error.code).toBe('SCB_NOT_CONFIGURED') + expect(mockSupabase.from).not.toHaveBeenCalled() + }) + + it('returns 404 for a party outside the company', async () => { + enqueue({ data: null }) + expect((await parseJsonResponse(await call())).status).toBe(404) + expect(lookupByOrgNumber).not.toHaveBeenCalled() + }) + + it('refuses a sole trader with 400 and never calls SCB', async () => { + enqueue({ data: { id: PARTY, org_number: '8001011234', legal_name: null } }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await call()) + expect(status).toBe(400) + expect(body.error.code).toBe('SCB_NOT_A_LEGAL_PERSON') + expect(lookupByOrgNumber).not.toHaveBeenCalled() + }) + + it('maps an SCB failure to 502', async () => { + enqueue({ data: { id: PARTY, org_number: '5560125790', legal_name: null } }) + lookupByOrgNumber.mockRejectedValue(new Error('boom')) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await call()) + expect(status).toBe(502) + expect(body.error.code).toBe('SCB_LOOKUP_FAILED') + }) + + it('reports not found without writing anything', async () => { + enqueue({ data: { id: PARTY, org_number: '5560125790', legal_name: null } }) + lookupByOrgNumber.mockResolvedValue({ found: false, peOrgNr: '165560125790', row: null, facts: [], fetchedAt: '2026-09-03T10:00:00Z' }) + const { status, body } = await parseJsonResponse<{ data: { found: boolean; orgNumber: string } }>(await call()) + expect(status).toBe(200) + expect(body.data).toMatchObject({ found: false, orgNumber: '5560125790' }) + expect(mockSupabase.rpc).not.toHaveBeenCalled() + }) + + it('records the facts with provenance and fills an empty legal name', async () => { + enqueue({ data: { id: PARTY, org_number: '5560125790', legal_name: null } }) + lookupByOrgNumber.mockResolvedValue({ + found: true, + peOrgNr: '165560125790', + row: {}, + facts: [ + { field: 'legal_name', value: 'Beijer Byggmaterial AB' }, + { field: 'f_tax', value: { code: '1', label: 'Godkänd för F-skatt' } }, + ], + fetchedAt: '2026-09-03T10:00:00Z', + }) + enqueue({ data: { inserted: 2, superseded: 0, refreshed: 0 } }) + enqueue({ data: null, count: 0 }) // no user-entered legal name + enqueue({ data: null }) // parties.update + const { status, body } = await parseJsonResponse<{ data: { found: boolean; inserted: number; facts: unknown[] } }>(await call()) + expect(status).toBe(200) + expect(body.data).toMatchObject({ found: true, inserted: 2 }) + expect(body.data.facts).toHaveLength(2) + expect(mockSupabase.rpc).toHaveBeenCalledWith('record_party_facts', { + p_company_id: 'company-1', + p_user_id: 'user-1', + p_party_id: PARTY, + p_source: 'registry_scb', + p_facts: [ + { field: 'legal_name', value: 'Beijer Byggmaterial AB', reference: { layout: 'Je', pe_org_nr: '165560125790' } }, + { field: 'f_tax', value: { code: '1', label: 'Godkänd för F-skatt' }, reference: { layout: 'Je', pe_org_nr: '165560125790' } }, + ], + p_fetched_at: '2026-09-03T10:00:00Z', + }) + expect(lookupByOrgNumber).toHaveBeenCalledWith('5560125790') + }) +}) + +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' } }) + 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 + const { status } = await parseJsonResponse(await call()) + expect(status).toBe(200) + const updates = mockSupabase.from.mock.calls.filter((c) => c[0] === 'parties').length + // one lookup, no update + expect(updates).toBe(1) + }) +}) + +describe('GET /api/parties/[id]/enrich/candidates', () => { + it('returns 503 when SCB is not configured and 404 for a foreign party', async () => { + configured.value = false + expect((await parseJsonResponse(await candidates())).status).toBe(503) + configured.value = true + enqueue({ data: null }) + expect((await parseJsonResponse(await candidates())).status).toBe(404) + expect(searchByName).not.toHaveBeenCalled() + }) + + it('searches on the party name by default and on q when given', async () => { + const result = { query: 'Adobe Systems Software', mode: 'starts_with', total: 2, truncated: false, candidates: [] } + enqueue({ data: { id: PARTY, display_name: 'Adobe Systems Software', legal_name: null } }) + searchByName.mockResolvedValue(result) + const a = await parseJsonResponse<{ data: typeof result }>(await candidates()) + expect(a.status).toBe(200) + expect(a.body.data).toEqual(result) + expect(searchByName).toHaveBeenLastCalledWith('Adobe Systems Software') + enqueue({ data: { id: PARTY, display_name: 'Adobe Systems Software', legal_name: null } }) + await candidates('Adobe Nordic') + expect(searchByName).toHaveBeenLastCalledWith('Adobe Nordic') + }) + + it('maps an SCB failure to 502', async () => { + enqueue({ data: { id: PARTY, display_name: 'Adobe', legal_name: null } }) + searchByName.mockRejectedValue(new Error('boom')) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await candidates()) + expect(status).toBe(502) + expect(body.error.code).toBe('SCB_LOOKUP_FAILED') + }) +}) + +describe('POST /api/parties/[id]/enrich with a picked org number', () => { + it('rejects a malformed number, a sole trader, and a party that already has one', async () => { + expect((await parseJsonResponse(await callWith({ orgNumber: '12' }))).status).toBe(400) + enqueue({ data: { id: PARTY, org_number: null, legal_name: null, vat_number: null } }) + expect((await parseJsonResponse(await callWith({ orgNumber: '8001011234' }))).status).toBe(400) + enqueue({ data: { id: PARTY, org_number: '5564300142', legal_name: null, vat_number: null } }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await callWith({ orgNumber: '5564082161' })) + expect(status).toBe(409) + expect(body.error.code).toBe('CONFLICT') + expect(lookupByOrgNumber).not.toHaveBeenCalled() + }) + + it('refuses a number another live party already holds, naming it', async () => { + enqueue({ data: { id: PARTY, org_number: null, legal_name: null, vat_number: null } }) + enqueue({ data: { id: OTHER, display_name: 'Adobe Systems Nordic AB' } }) + const { status, body } = await parseJsonResponse<{ error: { code: string; details: { reason: string; displayName: string } } }>(await callWith({ orgNumber: '5564082161' })) + expect(status).toBe(409) + expect(body.error.details).toMatchObject({ reason: 'org_number_taken', displayName: 'Adobe Systems Nordic AB' }) + expect(lookupByOrgNumber).not.toHaveBeenCalled() + }) + + it('sets the number as a user fact, then fetches by number', async () => { + enqueue({ data: { id: PARTY, org_number: null, legal_name: null, vat_number: null } }) + enqueue({ data: null }) // no holder + enqueue({ data: null }) // parties.update org_number + enqueue({ data: { inserted: 1, superseded: 0, refreshed: 0 } }) // record_party_facts (user) + lookupByOrgNumber.mockResolvedValue({ found: true, peOrgNr: '165564082161', row: {}, facts: [{ field: 'legal_name', value: 'Adobe Systems Nordic Aktiebolag' }], fetchedAt: '2026-09-03T10:00:00Z' }) + enqueue({ data: { inserted: 1, superseded: 0, refreshed: 0 } }) // record_party_facts (registry) + enqueue({ data: null, count: 0 }) // no user legal name + enqueue({ data: null }) // parties.update legal_name + const { status, body } = await parseJsonResponse<{ data: { found: boolean; orgNumber: string } }>(await callWith({ orgNumber: '556408-2161' })) + expect(status).toBe(200) + expect(body.data).toMatchObject({ found: true, orgNumber: '5564082161' }) + expect(mockSupabase.rpc).toHaveBeenNthCalledWith(1, 'record_party_facts', expect.objectContaining({ p_source: 'user', p_facts: [{ field: 'org_number', value: '5564082161', reference: { picked_from: 'scb_search' } }] })) + expect(lookupByOrgNumber).toHaveBeenCalledWith('5564082161') + }) +}) diff --git a/app/api/parties/[id]/enrich/candidates/route.ts b/app/api/parties/[id]/enrich/candidates/route.ts new file mode 100644 index 00000000..3a830572 --- /dev/null +++ b/app/api/parties/[id]/enrich/candidates/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateQuery } from '@/lib/api/validate' +import { PartySearchRegistryQuerySchema } from '@/lib/api/schemas' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { createScbClient } from '@/lib/parties/scb/client' +import { isScbConfigured, scbConfigFromEnv } from '@/lib/parties/scb/config' +import { ScbApiError } from '@/lib/parties/scb/transport' + +/** + * GET /api/parties/[id]/enrich/candidates?q=: SCB companies whose name + * matches, for the picker shown when a party has no org number. Never + * chooses; the user does, and the choice lands through POST .../enrich. + */ +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'parties.enrich.candidates', + async (request, { supabase, companyId, log, requestId }, { params }) => { + const { id } = await params + if (!/^[0-9a-f-]{36}$/i.test(id)) return errorResponseFromCode('NOT_FOUND', log, { requestId }) + const validated = validateQuery(request, PartySearchRegistryQuerySchema, { log, operation: 'parties.enrich.candidates' }) + if (!validated.success) return validated.response + if (!isScbConfigured()) return errorResponseFromCode('SCB_NOT_CONFIGURED', log, { requestId }) + + const { data: party, error } = await supabase + .from('parties') + .select('id, display_name, legal_name') + .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; display_name: string; legal_name: string | null } + const query = validated.data.q?.trim() || p.legal_name || p.display_name + + try { + const result = await createScbClient(scbConfigFromEnv()).searchByName(query) + return NextResponse.json({ data: result }) + } catch (err) { + log.warn('scb search failed', { partyId: id, status: err instanceof ScbApiError ? err.status : undefined, message: err instanceof Error ? err.message : String(err) }) + return errorResponseFromCode('SCB_LOOKUP_FAILED', log, { requestId }) + } + }, +) diff --git a/app/api/parties/[id]/enrich/route.ts b/app/api/parties/[id]/enrich/route.ts new file mode 100644 index 00000000..6d282d5c --- /dev/null +++ b/app/api/parties/[id]/enrich/route.ts @@ -0,0 +1,136 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { PartyEnrichSchema } from '@/lib/api/schemas' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { createScbClient } from '@/lib/parties/scb/client' +import { isScbConfigured, scbConfigFromEnv } from '@/lib/parties/scb/config' +import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number' +import { ScbApiError } from '@/lib/parties/scb/transport' + +/** + * POST /api/parties/[id]/enrich: fetch the party's registry facts from SCB + * and record them with provenance (source registry_scb, fetched_at). Legal + * persons only: a sole trader's org number is a personnummer and stays out + * of registry lookups in this phase. + * + * Body { orgNumber } is the picker's answer for a party that had none: the + * choice is recorded as a fact with source 'user' and set on the party + * before the fetch, so every later fetch is by number. A number already + * held by another live party is refused (merge them instead). + */ +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'parties.enrich', + async (request, { supabase, companyId, user, log, requestId }, { params }) => { + const { id } = await params + if (!/^[0-9a-f-]{36}$/i.test(id)) return errorResponseFromCode('NOT_FOUND', log, { requestId }) + if (!isScbConfigured()) return errorResponseFromCode('SCB_NOT_CONFIGURED', log, { requestId }) + // A body is optional (the plain button sends none); when present it is + // the picker's answer. Read as text first: fetch sets no content-length. + let chosen: string | undefined + const raw = (await request.text()).trim() + if (raw) { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return errorResponseFromCode('VALIDATION_ERROR', log, { requestId, details: { reason: 'invalid_json' } }) + } + const validation = PartyEnrichSchema.safeParse(parsed) + if (!validation.success) return errorResponseFromCode('VALIDATION_ERROR', log, { requestId, details: validation.error.flatten() }) + chosen = validation.data.orgNumber + } + + const { data: party, error } = await supabase + .from('parties') + .select('id, 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 } + + if (chosen && chosen !== p.org_number) { + if (!isLegalPersonOrgNumber(chosen)) return errorResponseFromCode('SCB_NOT_A_LEGAL_PERSON', log, { requestId }) + if (p.org_number) return errorResponseFromCode('CONFLICT', log, { requestId, details: { reason: 'party_has_org_number', orgNumber: p.org_number } }) + const { data: holder } = await supabase + .from('parties') + .select('id, display_name') + .eq('company_id', companyId) + .eq('org_number', chosen) + .is('merged_into', null) + .limit(1) + .maybeSingle() + if (holder) { + return errorResponseFromCode('CONFLICT', log, { requestId, details: { reason: 'org_number_taken', partyId: (holder as { id: string }).id, displayName: (holder as { display_name: string }).display_name } }) + } + const { error: setError } = await supabase.from('parties').update({ org_number: chosen }).eq('company_id', companyId).eq('id', id) + if (setError) throw new Error(`parties update failed: ${setError.message}`) + const { error: factError } = await supabase.rpc('record_party_facts', { + p_company_id: companyId, + p_user_id: user.id, + p_party_id: id, + p_source: 'user', + p_facts: [{ field: 'org_number', value: chosen, reference: { picked_from: 'scb_search' } }], + p_fetched_at: new Date().toISOString(), + }) + if (factError) throw new Error(`record_party_facts failed: ${factError.message}`) + p.org_number = chosen + } + + if (!isLegalPersonOrgNumber(p.org_number)) return errorResponseFromCode('SCB_NOT_A_LEGAL_PERSON', log, { requestId }) + + let lookup + try { + lookup = await createScbClient(scbConfigFromEnv()).lookupByOrgNumber(p.org_number!) + } catch (err) { + log.warn('scb lookup failed', { partyId: id, status: err instanceof ScbApiError ? err.status : undefined, message: err instanceof Error ? err.message : String(err) }) + return errorResponseFromCode('SCB_LOOKUP_FAILED', log, { requestId }) + } + + if (!lookup.found) { + return NextResponse.json({ data: { found: false, orgNumber: p.org_number, inserted: 0, superseded: 0, refreshed: 0 } }) + } + + const { data: summary, error: recordError } = await supabase.rpc('record_party_facts', { + p_company_id: companyId, + p_user_id: user.id, + p_party_id: id, + p_source: 'registry_scb', + p_facts: lookup.facts.map((f) => ({ ...f, reference: { ...(f.reference ?? {}), layout: 'Je', pe_org_nr: lookup.peOrgNr } })), + p_fetched_at: lookup.fetchedAt, + }) + if (recordError) throw new Error(`record_party_facts failed: ${recordError.message}`) + + // Survivorship (plan section 05): user > registry > document. The + // 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) { + const { count } = await supabase + .from('party_facts') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('party_id', id) + .eq('field', 'legal_name') + .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 VAT number has one valid form, so it fills an empty field outright. + const vat = lookup.facts.find((f) => f.field === 'vat_number')?.value + if (!p.vat_number && typeof vat === 'string' && vat) { + await supabase.from('parties').update({ vat_number: vat }).eq('company_id', companyId).eq('id', id) + } + + 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 }, + }) + }, + { requireWrite: true }, +) diff --git a/components/parties/PartyDossier.tsx b/components/parties/PartyDossier.tsx index 2a17b44c..b7181b1d 100644 --- a/components/parties/PartyDossier.tsx +++ b/components/parties/PartyDossier.tsx @@ -1,8 +1,11 @@ 'use client' import { useEffect, useState } from 'react' +import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number' import { useTranslations } from 'next-intl' +import { MoreHorizontal } from 'lucide-react' import { Button } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { VTD_CLASS, VTH_CLASS } from '@/components/ui/dry-table' import { Skeleton } from '@/components/ui/skeleton' import { SlideOver, SlideOverBody, SlideOverContent, SlideOverHeader } from '@/components/ui/slide-over' @@ -16,6 +19,58 @@ 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 ( @@ -42,6 +97,9 @@ export function PartyDossier({ onPromote, onDismiss, onMerge, + onFetchRegistry, + onPickRegistry, + fetching = false, reloadKey, }: { partyId: string | null @@ -52,6 +110,11 @@ export function PartyDossier({ onPromote: (id: string, roles: PartyRole[]) => void onDismiss: (id: string) => void onMerge: (subject: MergeCandidate, suggested: MergeCandidate[]) => void + /** Fetch registry facts from SCB for this party; undefined hides the item. */ + onFetchRegistry?: (id: string) => void + /** Open the SCB picker for a party without an org number. */ + onPickRegistry?: (id: string, name: string) => void + fetching?: boolean reloadKey: number }) { const t = useTranslations('parties') @@ -99,10 +162,19 @@ export function PartyDossier({ const orgFact = dossier?.facts.find((f) => f.field === 'org_number') const docsFor = (field: string) => { const f = dossier?.facts.find((x) => x.field === field) - const n = (f?.reference as { docs?: number } | null)?.docs - return n ? t('fact_from_documents', { count: n }) : f?.source === 'ledger' ? t('fact_from_ledger') : f?.source === 'user' ? t('fact_from_user') : '' + if (!f) return '' + if (f.source === 'registry_scb') return t('source_scb') + const n = (f.reference as { docs?: number } | null)?.docs + return n ? t('fact_from_documents', { count: n }) : f.source === 'ledger' ? t('fact_from_ledger') : f.source === 'user' ? t('fact_from_user') : '' } const dominant = dossier?.facts.find((f) => f.field === 'dominant_account')?.value as { account?: string; count?: number } | undefined + const registryVat = dossier?.facts.find((f) => f.field === 'vat_number' && f.source === 'registry_scb')?.value + // One primary action: the role the ledger suggests and the party does not + // have yet. The other role and everything else live behind the menu. + const missingRoles: PartyRole[] = p ? (['supplier', 'customer'] as PartyRole[]).filter((r) => (r === 'supplier' ? !p.roles.supplierId : !p.roles.customerId)) : [] + const primaryRole: PartyRole | null = p ? (missingRoles.find((r) => p.defaultRoles.includes(r)) ?? missingRoles[0] ?? null) : null + const secondaryRole: PartyRole | null = missingRoles.find((r) => r !== primaryRole) ?? null + const scbFetchedAt = dossier?.facts.filter((f) => f.source === 'registry_scb').map((f) => f.fetchedAt ?? f.recordedAt).sort().at(-1) ?? null return ( (!open ? onClose() : undefined)}> @@ -121,48 +193,46 @@ export function PartyDossier({
{subtitle ?

{subtitle}

: null} -
- {suggested || !p.roles.supplierId ? ( - - ) : null} - {suggested || !p.roles.customerId ? ( - - ) : null} - - {suggested ? ( - ) : null} + + + + + + {secondaryRole ? ( + onPromote(p.id, [secondaryRole])}> + {secondaryRole === 'supplier' ? t('promote_supplier') : t('promote_customer')} + + ) : null} + {onFetchRegistry && isLegalPersonOrgNumber(p.orgNumber) ? ( + onFetchRegistry(p.id)} disabled={fetching}> + {fetching ? t('fetching_registry') : t('fetch_registry')} + + ) : onPickRegistry && !p.orgNumber && p.kind !== 'person' ? ( + onPickRegistry(p.id, p.legalName ?? p.displayName)} disabled={fetching}> + {t('pick_registry')} + + ) : null} + + onMerge( + { id: p.id, displayName: p.displayName, orgNumber: p.orgNumber, status: p.status }, + dossier.similar.map((s) => ({ id: s.id, displayName: s.displayName, orgNumber: s.orgNumber, status: s.status })), + ) + } + > + {t('merge')} + + {suggested ? onDismiss(p.id)}>{t('dismiss')} : null} + +
@@ -226,7 +296,11 @@ export function PartyDossier({ value={p.orgNumber ? formatOrgNumber(p.orgNumber) : {t('fact_missing')}} note={p.orgNumber && orgFact ? docsFor('org_number') : undefined} /> - {t('fact_missing')}} /> + {t('fact_missing')})} + note={p.vatNumber ? docsFor('vat_number') || undefined : registryVat ? docsFor('vat_number') : undefined} + /> {dossier.identities.map((i) => ( ))} + {scbFetchedAt && registryFacts(dossier.facts).length > 0 ? ( + + + {t('registry_group', { date: formatDate(scbFetchedAt) })} + + + ) : null} + {registryFacts(dossier.facts).map((f) => ( + + ))} diff --git a/components/parties/ScbPickerDialog.tsx b/components/parties/ScbPickerDialog.tsx new file mode 100644 index 00000000..1b485e0d --- /dev/null +++ b/components/parties/ScbPickerDialog.tsx @@ -0,0 +1,141 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import type { ScbCandidate, ScbSearchResult } from '@/lib/parties/scb/client' +import { formatOrgNumber } from '@/lib/utils' + +/** + * "SCB hittar två företag som liknar Adobe Systems Software, vilket menar + * du?" The picker for a party without an org number: the user chooses, + * the org number lands on the party, and every later fetch is by number. + * One match is still shown, never auto-picked. + */ +export function ScbPickerDialog({ + open, + onOpenChange, + partyId, + partyName, + busy, + onPick, +}: { + open: boolean + onOpenChange: (open: boolean) => void + partyId: string + partyName: string + busy: boolean + onPick: (candidate: ScbCandidate) => Promise +}) { + const t = useTranslations('parties') + const tCommon = useTranslations('common') + const [query, setQuery] = useState('') + const [loaded, setLoaded] = useState<{ key: string; result: ScbSearchResult | null; failed: boolean } | null>(null) + const [selected, setSelected] = useState(null) + + const key = `${partyId}:${query.trim()}` + const current = loaded && loaded.key === key ? loaded : null + const loading = open && current === null + + useEffect(() => { + if (!open) return + let cancelled = false + const ctrl = new AbortController() + const timer = setTimeout(async () => { + try { + const params = query.trim() ? `?q=${encodeURIComponent(query.trim())}` : '' + const res = await fetch(`/api/parties/${partyId}/enrich/candidates${params}`, { signal: ctrl.signal }) + const json = (await res.json()) as { data?: ScbSearchResult } + if (!cancelled) setLoaded({ key, result: res.ok ? (json.data ?? null) : null, failed: !res.ok }) + } catch { + if (!cancelled) setLoaded({ key, result: null, failed: true }) + } + }, query.trim() ? 300 : 0) + return () => { + cancelled = true + clearTimeout(timer) + ctrl.abort() + } + }, [open, partyId, query, key]) + + const result = current?.result ?? null + const candidates = result?.candidates ?? [] + const chosen = candidates.find((c) => c.orgNumber === selected) ?? null + + return ( + + + + {t('picker_title')} + + {result && !current?.failed + ? result.truncated + ? t('picker_too_many', { count: result.total, query: result.query }) + : candidates.length === 0 + ? t('picker_none', { query: result.query }) + : t('picker_found', { count: candidates.length, query: result.query }) + : t('picker_body', { name: partyName })} + + +
+ { + setQuery(e.target.value) + setSelected(null) + }} + placeholder={t('picker_search_placeholder')} + aria-label={t('picker_search_placeholder')} + /> + {loading ? ( +
+ + +
+ ) : current?.failed ? ( +

{t('registry_unavailable_title')}

+ ) : candidates.length > 0 ? ( +
    + {candidates.map((c) => { + const isSelected = selected === c.orgNumber + return ( +
  • + +
  • + ) + })} +
+ ) : null} +
+ + + + +
+
+ ) +} diff --git a/components/parties/SuggestionQueue.tsx b/components/parties/SuggestionQueue.tsx index 09bc8912..25854a38 100644 --- a/components/parties/SuggestionQueue.tsx +++ b/components/parties/SuggestionQueue.tsx @@ -107,7 +107,7 @@ export function SuggestionQueue({ ) : null} - {reasonText(t, row.reason, row.stats?.rhythm ?? null)} + {reasonText(t, row.reason, row.stats?.rhythm ?? null, row.orgNumber)} diff --git a/components/parties/format.ts b/components/parties/format.ts index 479e42a5..5f4bf900 100644 --- a/components/parties/format.ts +++ b/components/parties/format.ts @@ -25,16 +25,20 @@ export function rolesLabel(t: Translate, roles: PartyRole[]): string { } /** "Org.nr 556354-5185 i 3 underlag · 12 verifikat · varje månad": why a row is in the queue. */ -export function reasonText(t: Translate, reason: SuggestionReason | null, rhythm: LedgerStats['rhythm']): string { +export function reasonText(t: Translate, reason: SuggestionReason | null, rhythm: LedgerStats['rhythm'], orgNumber: string | null = null): string { if (!reason) return '' const parts: string[] = [] const docs = Math.max(0, (reason.docs ?? 0) - (reason.self_docs ?? 0)) + // An org number the person picked from the register (no document carries + // it) is stated as such; the stored reason predates the pick. + const picked = Boolean(orgNumber) && !reason.org_number if (reason.org_number) parts.push(t('reason_org', { org: formatOrgNumber(reason.org_number), docs })) + else if (picked) parts.push(t('reason_org_picked', { org: formatOrgNumber(orgNumber!) })) else if (reason.ambiguous_orgs?.length) parts.push(t('reason_ambiguous')) else if (docs > 0) parts.push(t('reason_docs', { docs })) parts.push(t('reason_vouchers', { count: reason.occurrences ?? 0 })) if (rhythm && rhythm !== 'irregular') parts.push(rhythmLabel(t, rhythm)) - if (!reason.org_number && !reason.ambiguous_orgs?.length && docs === 0) parts.push(t('reason_ledger_only')) + if (!reason.org_number && !picked && !reason.ambiguous_orgs?.length && docs === 0) parts.push(t('reason_ledger_only')) if (reason.similar_to?.length) parts.push(t('reason_similar', { name: reason.similar_to[0]!.display_name })) return parts.join(' · ') } @@ -52,5 +56,5 @@ export function isDuplicateCandidate(row: RegisterRow): boolean { } export function hasHardKey(row: RegisterRow): boolean { - return Boolean(row.reason?.org_number) + return Boolean(row.reason?.org_number || row.orgNumber) } diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 63ca8afa..5cc394aa 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -4030,6 +4030,19 @@ export const PartyMergeSchema = z.object({ note: z.string().max(500).optional(), }) +export const PartyEnrichSchema = z.object({ + /** Chosen from the SCB picker: sets the party's org number before the fetch. */ + orgNumber: z + .string() + .transform((v) => v.replace(/[^0-9]/g, '')) + .pipe(z.string().regex(/^\d{10}$/)) + .optional(), +}) + +export const PartySearchRegistryQuerySchema = z.object({ + q: z.string().max(120).optional(), +}) + export const PartyUndoMergeSchema = z.object({ decisionId: uuid, }) diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 3345ebe9..6c2e02db 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1514,6 +1514,21 @@ const INVOICE: Record = { // POST /api/invoices/{id}/peppol/send. The Access Point is an environment // decision (PEPPOL_TRANSPORT_PROVIDER + adapter credentials); the product // never pretends to send when no adapter is switched on. + SCB_NOT_CONFIGURED: { + httpStatus: 503, + message_sv: 'Uppslag mot SCB:s företagsregister är inte aktiverat i den här miljön.', + message_en: 'Lookups against the SCB business register are not enabled in this environment.', + }, + SCB_LOOKUP_FAILED: { + httpStatus: 502, + message_sv: 'SCB:s företagsregister svarade inte. Försök igen om en stund.', + message_en: 'The SCB business register did not answer. Try again shortly.', + }, + SCB_NOT_A_LEGAL_PERSON: { + httpStatus: 400, + message_sv: 'Uppgifter hämtas bara för juridiska personer, inte för enskilda firmor.', + message_en: 'Details are fetched for legal persons only, not for sole traders.', + }, PEPPOL_TRANSPORT_UNAVAILABLE: { httpStatus: 503, message_sv: 'Peppol-utskick är inte aktiverat i den här miljön. En avtalad Peppol-operatör måste vara konfigurerad.', diff --git a/lib/parties/register.ts b/lib/parties/register.ts index c6d7f5f5..7f34c3f1 100644 --- a/lib/parties/register.ts +++ b/lib/parties/register.ts @@ -14,6 +14,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { roundOre } from '@/lib/money' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { coreKey } from './ledger-key' +import { isScbConfigured } from './scb/config' import { getObservedParties, type ObservedParty } from './observed' import type { SuggestionReason } from './suggest' @@ -80,6 +81,8 @@ export interface Register { /** Observed keys the pre-classifier calls a category: unattributed spend. */ generic: { count: number; expenseSek: number; examples: string[] } period: RegisterPeriod + /** Whether this environment can fetch registry facts from SCB (gates the dossier button). */ + scbConfigured: boolean } interface PartyRecord { @@ -335,7 +338,7 @@ export async function getRegister( .sort((a, b) => b.stats.expenseSek + b.stats.revenueSek - (a.stats.expenseSek + a.stats.revenueSek)) : [] - return { counts, rows: selected, observed: observedSelected, generic, period } + return { counts, rows: selected, observed: observedSelected, generic, period, scbConfigured: isScbConfigured() } } // ── Dossier ───────────────────────────────────────────────────────────────── diff --git a/lib/parties/scb/__tests__/fixtures/volvo-je.json b/lib/parties/scb/__tests__/fixtures/volvo-je.json new file mode 100644 index 00000000..b1616d75 --- /dev/null +++ b/lib/parties/scb/__tests__/fixtures/volvo-je.json @@ -0,0 +1,101 @@ +{ + "PeOrgNr": "165560125790", + "OrgNr": "5560125790", + "Företagsnamn": "AKTIEBOLAGET VOLVO", + "COAdress": "", + "PostAdress": "", + "PostNr": "405 08", + "PostOrt": "GÖTEBORG", + "Säteskommun, kod": "1480", + "Säteskommun": "Göteborg", + "Säteslän, kod": "14", + "Säteslän": "Västra Götaland", + "Aregion, kod": "33", + "ARegion": "Göteborg", + "Antal arbetsställen": "1 ", + "Stkl, kod": "8 ", + "Storleksklass": "200-499 anställda", + "Företagsstatus, kod": "1", + "Företagsstatus": "Är verksam", + "Registrerad hos SKV, kod": "1", + "Registrerad hos SKV": "Registrerad", + "Juridisk form, kod": "49", + "Juridisk form": "Övriga aktiebolag", + "Reklam, kod": "11", + "Reklam": "Tar emot reklam, ej telefonnummerspärrat", + "Utskick, kod": "1", + "Utskick": "Postadress är OK", + "Startdatum": "1972-01-01", + "Slutdatum": "", + "Registreringsdatum": "1972-01-01", + "Bransch_1, kod": "70100", + "Bransch_1P, kod": "70.100", + "Bransch_1": "Verksamheter som utövas av huvudkontor", + "Avdelning_1, kod": "N", + "Avdelning_1": "Verksamhet inom juridik, ekonomi, vetenskap och teknik", + "Bransch_2, kod": " ", + "Bransch_2P, kod": " ", + "Bransch_2": "", + "Avdelning_2, kod": " ", + "Avdelning_2": "", + "Bransch_3, kod": " ", + "Bransch_3P, kod": " ", + "Bransch_3": "", + "Avdelning_3, kod": " ", + "Avdelning_3": "", + "Bransch_4, kod": " ", + "Bransch_4P, kod": " ", + "Bransch_4": "", + "Avdelning_4, kod": " ", + "Avdelning_4": "", + "Bransch_5, kod": " ", + "Bransch_5P, kod": " ", + "Bransch_5": "", + "Avdelning_5, kod": " ", + "Avdelning_5": "", + "Export/Importmarkering": "J", + "Omsättning, år": "2025", + "Stkl, oms, kod": "10", + "Storleksklass, oms": "1 000 000 - 4 999 999 tkr", + "Stkl Fin, oms, kod": "19", + "Storleksklass Fin, oms": "1 000 000 - 4 999 999 tkr", + "Ägarkategori, kod": "42", + "Ägarkategori": "Privat svenskt med koncern", + "Telefon": "031660000", + "E-post": "", + "Privat/Publikt, kod": "21", + "Privat/Publikt": "Publikt, Stockholm Large Cap", + "Arbetsgivarstatus, kod": "1", + "Arbetsgivarstatus": "Är registrerad som vanlig arbetsgivare", + "Momsstatus, kod": "1", + "Momsstatus": "Är registrerad för moms", + "Fskattstatus, kod": "1", + "Fskattstatus": "Är registrerad för F-skatt", + "Bolagsstatus, kod": "0 ", + "Bolagsstatus": "Normalläge", + "Antal firmor": "0 ", + "Firma": "", + "Sektor, kod": "111000", + "Sektor": "Icke-finansiella bolag, utom statliga affärsverk, filialer i Sverige till icke-finansiella bolag i utlandet och icke-vinstdrivande organisationer som betjänar icke-finansiella bolag", + "Stkl SME, kod": "4 ", + "Storleksklass SME": "250-499 anställda", + "Andel kvinna": "*", + "Andel man": "*", + "Ägarland, kod": "*", + "Ägarland": "*", + "Ägarnamn": "*", + "Utländskt ägande, kod": "*", + "Utländskt ägande": "*", + "Stkl export, kod": "1", + "Stkl export": "1-249 tkr", + "Stkl import, kod": "2", + "Stkl import": "250-999 tkr", + "Norden": "Ingen export/import", + "EU": "Import", + "Övriga Europa": "Export", + "Asien, ej Fjärran Östern": "Export", + "Fjärran Östern": "Import", + "Nord- och Centralamerika": "Export", + "Sydamerika": "Ingen export/import", + "Afrika": "Ingen export/import" +} \ No newline at end of file diff --git a/lib/parties/scb/__tests__/scb.test.ts b/lib/parties/scb/__tests__/scb.test.ts new file mode 100644 index 00000000..b8715420 --- /dev/null +++ b/lib/parties/scb/__tests__/scb.test.ts @@ -0,0 +1,218 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createScbClient, identityLookupBody, nameQuery, nameSearchBody, SCB_SEARCH_CAP } from '../client' +import { isScbConfigured, scbConfigFromEnv } from '../config' +import { factsFromScbCompany, BOLAGSVERKET_WARNING_CODES } from '../map' +import { isLegalPersonOrgNumber, toPeOrgNr } from '../org-number' +import { ScbApiError, scbJson } from '../transport' + +/** One Je row exactly as the live API returned it on 2026-09-03 (AB Volvo, public registry data). */ +const volvo = JSON.parse(readFileSync(join(__dirname, 'fixtures', 'volvo-je.json'), 'utf8')) as Record + +describe('org numbers we send to SCB', () => { + it('accepts legal persons (month slot 20 or more) and refuses personnummer-shaped numbers', () => { + expect(isLegalPersonOrgNumber('556012-5790')).toBe(true) + expect(isLegalPersonOrgNumber('5564300142')).toBe(true) + expect(isLegalPersonOrgNumber('9696789012')).toBe(true) + expect(isLegalPersonOrgNumber('19800101-1234')).toBe(false) + expect(isLegalPersonOrgNumber('8001011234')).toBe(false) + expect(isLegalPersonOrgNumber('')).toBe(false) + expect(isLegalPersonOrgNumber(null)).toBe(false) + }) + + it('builds PeOrgNr with the 16 prefix', () => { + expect(toPeOrgNr('556012-5790')).toBe('165560125790') + }) +}) + +describe('config', () => { + it('is configured only when both the certificate and its password are set', () => { + const env = (v: Record) => v as unknown as NodeJS.ProcessEnv + expect(isScbConfigured(env({}))).toBe(false) + expect(isScbConfigured(env({ SCB_API_CERT_PFX_BASE64: 'AAAA' }))).toBe(false) + expect(isScbConfigured(env({ SCB_API_CERT_PFX_BASE64: 'AAAA', SCB_API_CERT_PASSWORD: 'x' }))).toBe(true) + const cfg = scbConfigFromEnv(env({ SCB_API_CERT_PFX_BASE64: Buffer.from('pfx').toString('base64'), SCB_API_CERT_PASSWORD: 'x', SCB_API_BASE_URL: 'https://example.test/base/' })) + expect(cfg.baseUrl).toBe('https://example.test/base') + expect(cfg.pfx.toString()).toBe('pfx') + expect(() => scbConfigFromEnv(env({}))).toThrow(/SCB_API_CERT_PFX_BASE64/) + }) +}) + +describe('factsFromScbCompany on a live row', () => { + it('maps the Volvo row with SCB codes and SCB text', () => { + const facts = factsFromScbCompany(volvo) + const by = Object.fromEntries(facts.map((f) => [f.field, f.value])) + expect(by.legal_name).toBe('AKTIEBOLAGET VOLVO') + expect(by.trade_name).toBeUndefined() + expect(by.f_tax).toEqual({ code: '1', label: 'Är registrerad för F-skatt' }) + expect(by.vat_registration).toEqual({ code: '1', label: 'Är registrerad för moms' }) + expect(by.employer_registration).toEqual({ code: '1', label: 'Är registrerad som vanlig arbetsgivare' }) + expect(by.company_status).toEqual({ code: '1', label: 'Är verksam' }) + expect(by.legal_form).toEqual({ code: '49', label: 'Övriga aktiebolag' }) + expect(by.bolagsverket_status).toEqual({ code: '0', label: 'Normalläge', warning: false }) + expect(by.employees_band).toEqual({ code: '8', label: '200-499 anställda' }) + expect(by.registered_skv).toBeUndefined() + expect(by.vat_number).toBe('SE556012579001') + expect(by.industry).toEqual({ code: '70100', label: 'Verksamheter som utövas av huvudkontor' }) + expect(by.postal_address).toEqual({ street: null, co: null, postal_code: '405 08', city: 'GÖTEBORG' }) + expect(by.seat).toEqual({ municipality_code: '1480', county_code: '14', municipality: 'Göteborg', county: 'Västra Götaland' }) + expect(by.turnover_band).toEqual({ code: '10', label: '1 000 000 - 4 999 999 tkr', year: '2025' }) + expect(by.registered_at).toBe('1972-01-01') + expect(by.active_since).toBe('1972-01-01') + expect(by.active_until).toBeUndefined() + expect(by.phone).toBe('031660000') + expect(by.email).toBeUndefined() + expect(by.workplaces).toBe(1) + }) + + it('flags a company in konkurs, falls back to our labels without SCB text, and tolerates an empty row', () => { + const facts = factsFromScbCompany({ Företagsnamn: 'Gone AB', 'Bolagsstatus, kod': '20', 'Fskattstatus, kod': '9 ' }) + const by = Object.fromEntries(facts.map((f) => [f.field, f.value])) + expect(by.legal_name).toBe('Gone AB') + expect(by.bolagsverket_status).toEqual({ code: '20', label: 'Konkurs inledd', warning: true }) + expect(by.f_tax).toEqual({ code: '9', label: 'Avregistrerad för F-skatt' }) + expect(by.vat_number).toBeUndefined() + expect(factsFromScbCompany({ OrgNr: '5560125790', 'Momsstatus, kod': '9' }).find((f) => f.field === 'vat_number')).toBeUndefined() + expect(BOLAGSVERKET_WARNING_CODES.has('0')).toBe(false) + expect(BOLAGSVERKET_WARNING_CODES.has('49')).toBe(false) // fusion pågår + expect(BOLAGSVERKET_WARNING_CODES.has('41')).toBe(true) // upplöst genom fusion + expect(factsFromScbCompany({})).toEqual([]) + }) +}) + +describe('createScbClient', () => { + const cfg = { baseUrl: 'https://scb.test', pfx: Buffer.from('x'), passphrase: 'p', timeoutMs: 1 } + + it('sends the identity filter the live API accepts', () => { + expect(identityLookupBody('5560125790')).toEqual({ + Variabler: [{ Variabel: 'OrgNr (10 siffror)', Operator: 'ArLikaMed', Varde1: '5560125790', Varde2: '' }], + Kategorier: [], + }) + }) + + it('refuses a sole trader before any call is made', async () => { + const json = async () => { + throw new Error('should not be called') + } + const client = createScbClient(cfg, { json: json as never }) + await expect(client.lookupByOrgNumber('8001011234')).rejects.toThrow(/juridiska personer/) + }) + + it('posts HamtaForetag and maps the returned row', async () => { + const calls: Array<{ method: string; path: string; body: unknown }> = [] + const json = async (_c: unknown, method: string, path: string, body?: unknown) => { + calls.push({ method, path, body }) + return [volvo] + } + const client = createScbClient(cfg, { json: json as never }) + const r = await client.lookupByOrgNumber('556012-5790') + expect(calls[0]!.method).toBe('POST') + expect(calls[0]!.path).toBe('/api/Je/HamtaForetag') + expect(calls[0]!.body).toEqual(identityLookupBody('5560125790')) + expect(r.found).toBe(true) + expect(r.peOrgNr).toBe('165560125790') + expect(r.facts.find((f) => f.field === 'legal_name')?.value).toBe('AKTIEBOLAGET VOLVO') + }) + + it('reports not found when the list is empty', async () => { + const json = async () => [] + const client = createScbClient(cfg, { json: json as never }) + const r = await client.lookupByOrgNumber('5564300142') + expect(r.found).toBe(false) + expect(r.facts).toEqual([]) + }) +}) + +describe('name search', () => { + it('strips AP prefixes, numbers and legal forms from the query', () => { + expect(nameQuery('Levfakt Telia Sverige AB (17)')).toBe('Telia Sverige') + expect(nameQuery('Leverantörsfaktura från 18 Loopia')).toBe('Loopia') + expect(nameQuery('Adobe Systems Software')).toBe('Adobe Systems Software') + expect(nameQuery("O'Learys Sundsvall AB")).toBe('OLearys Sundsvall') + // Foreign legal forms stay: they are part of the registered name and dropping them floods. + expect(nameQuery('Schmidt GmbH')).toBe('Schmidt GmbH') + expect(nameQuery('Google Cloud EMEA Limited')).toBe('Google Cloud EMEA Limited') + expect(nameSearchBody('Telia', 'starts_with').Variabler[0]).toEqual({ Variabel: 'Namn', Operator: 'BorjarPa', Varde1: 'Telia', Varde2: '' }) + expect(nameSearchBody('Telia', 'contains').Variabler[0]!.Operator).toBe('Innehaller') + }) + + const cfg = { baseUrl: 'https://scb.test', pfx: Buffer.from('x'), passphrase: 'p', timeoutMs: 1 } + const row = (org: string, name: string, statusCode = '1', legalForm = '49', city = 'STOCKHOLM') => ({ + OrgNr: org, + Företagsnamn: name, + PostOrt: city, + Bransch_1: 'Utgivning av annan programvara', + 'Företagsstatus, kod': statusCode, + Företagsstatus: statusCode === '1' ? 'Är verksam' : 'Är ej längre verksam', + 'Juridisk form, kod': legalForm, + 'Juridisk form': 'Övriga aktiebolag', + }) + + it('counts first, prefers a prefix match, sorts active companies first and drops natural persons', async () => { + const calls: string[] = [] + const json = async (_c: unknown, _m: string, path: string, body: { Variabler: Array<{ Operator: string }> }) => { + calls.push(`${path}:${body.Variabler[0]!.Operator}`) + if (path.endsWith('RaknaForetag')) return 3 + return [row('5020594593', 'ADOBE SYSTEMS SOFTWARE IRELAND LTD', '9'), row('5564082161', 'Adobe Systems Nordic Aktiebolag'), row('8001011234', 'ADOBE, ANNA', '1', '10')] + } + const client = createScbClient(cfg, { json: json as never }) + const r = await client.searchByName('Levfakt Adobe Systems (2)') + expect(calls).toEqual(['/api/Je/RaknaForetag:BorjarPa', '/api/Je/HamtaForetag:BorjarPa']) + expect(r.mode).toBe('starts_with') + expect(r.total).toBe(2) + expect(r.candidates.map((c) => [c.name, c.active])).toEqual([ + ['Adobe Systems Nordic Aktiebolag', true], + ['ADOBE SYSTEMS SOFTWARE IRELAND LTD', false], + ]) + }) + + it('falls back to a contains match when the prefix finds nothing, and refuses to pull a flood', async () => { + const calls: string[] = [] + const json = async (_c: unknown, _m: string, path: string, body: { Variabler: Array<{ Operator: string; Varde1: string }> }) => { + calls.push(`${path}:${body.Variabler[0]!.Operator}`) + if (path.endsWith('RaknaForetag')) return body.Variabler[0]!.Operator === 'BorjarPa' ? 0 : 593 + throw new Error('should not fetch rows for a flood') + } + const client = createScbClient(cfg, { json: json as never }) + const r = await client.searchByName('UBER') + expect(calls).toEqual(['/api/Je/RaknaForetag:BorjarPa', '/api/Je/RaknaForetag:Innehaller']) + expect(r).toMatchObject({ mode: 'contains', total: 593, truncated: true, candidates: [] }) + expect(SCB_SEARCH_CAP).toBe(25) + }) + + it('does not call SCB for a query shorter than two characters', async () => { + const json = async () => { + throw new Error('should not be called') + } + const r = await createScbClient(cfg, { json: json as never }).searchByName('Levfakt 17') + expect(r.candidates).toEqual([]) + }) +}) + +describe('scbJson', () => { + const cfg = { baseUrl: 'https://scb.test', pfx: Buffer.from('x'), passphrase: 'p', timeoutMs: 1 } + + it('retries once on a dropped connection, then succeeds', async () => { + let n = 0 + const request = async () => { + n += 1 + if (n === 1) throw Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }) + return { status: 200, body: '3' } + } + await expect(scbJson(cfg, 'POST', '/api/Je/RaknaForetag', {}, { request: request as never, delayMs: 0 })).resolves.toBe(3) + expect(n).toBe(2) + }) + + it('does not retry a non-transient error or a bad status', async () => { + let n = 0 + const boom = async () => { + n += 1 + throw new Error('certificate unknown') + } + await expect(scbJson(cfg, 'GET', '/x', undefined, { request: boom as never, delayMs: 0 })).rejects.toThrow(/certificate/) + expect(n).toBe(1) + const bad = async () => ({ status: 400, body: '{"Message":"Ogiltigt"}' }) + await expect(scbJson(cfg, 'GET', '/x', undefined, { request: bad as never })).rejects.toBeInstanceOf(ScbApiError) + }) +}) diff --git a/lib/parties/scb/client.ts b/lib/parties/scb/client.ts new file mode 100644 index 00000000..a1dbfe3c --- /dev/null +++ b/lib/parties/scb/client.ts @@ -0,0 +1,147 @@ +import type { ScbConfig } from './config' +import { factsFromScbCompany, type ScbCompanyRow, type ScbFact } from './map' +import { isLegalPersonOrgNumber, toPeOrgNr } from './org-number' +import { scbJson } from './transport' + +/** + * The wire format of the current SokPaVar API, checked against the live + * service on 2026-09-03 (scripts/scb/discover.ts, help page at + * /help). A search is a list of variable filters; an identity lookup + * is one filter on "OrgNr (10 siffror)" with operator ArLikaMed, and + * without Företagsstatus/Registreringsstatus so a deregistered company is + * still returned (an empty string there is rejected with 400). The row + * comes back with every purchased column, codes and texts side by side. + */ +export const SCB_ORG_VARIABLE = 'OrgNr (10 siffror)' + +export interface ScbLookupResult { + found: boolean + peOrgNr: string + row: ScbCompanyRow | null + facts: ScbFact[] + fetchedAt: string +} + +export interface ScbCandidate { + orgNumber: string + name: string + city: string | null + industry: string | null + legalForm: string | null + /** SCB's own status text; active is Företagsstatus code 1. */ + status: string | null + active: boolean +} + +export interface ScbSearchResult { + query: string + /** How SCB was asked: a prefix match first, a contains match as fallback. */ + mode: 'starts_with' | 'contains' + /** Rows SCB counted before the cap; above the cap the list is cut and the user should refine. */ + total: number + truncated: boolean + candidates: ScbCandidate[] +} + +export interface ScbClient { + variables(): Promise + categories(): Promise + lookupByOrgNumber(orgNumber: string): Promise + searchByName(query: string): Promise +} + +/** Candidates shown per search; SCB can return thousands for a short word. */ +export const SCB_SEARCH_CAP = 25 +/** Legal forms never offered in the picker: natural persons and estates. */ +const NON_COMPANY_LEGAL_FORMS = new Set(['10', '91']) + +/** + * What we send SCB for a name: the AP prefix, supplier numbers and a + * trailing legal form are noise ("Levfakt Telia Sverige AB (17)" becomes + * "Telia Sverige"). SCB's name filter refuses an apostrophe. + */ +export function nameQuery(raw: string): string { + return raw + .replace(/^(levfakt|levfkt|lev\.?fakt\.?|leverantörsfaktura från\s*\d*|leverantörsfaktura|levbet\.?|kundbet\.?|kundfaktura|faktura från|faktura|kvitto|utgift|inköp)\s+/i, '') + .replace(/[(),]/g, ' ') + .replace(/\b\d{1,6}\b/g, ' ') + .replace(/\s+(ab|aktiebolag|hb|kb|publ|\(publ\))\.?\s*$/i, '') + .replace(/'/g, '') + .replace(/\s+/g, ' ') + .trim() +} + +export function nameSearchBody(query: string, mode: 'starts_with' | 'contains') { + return { + Variabler: [{ Variabel: 'Namn', Operator: mode === 'starts_with' ? 'BorjarPa' : 'Innehaller', Varde1: query, Varde2: '' }], + Kategorier: [], + } +} + +function candidateFrom(row: ScbCompanyRow): ScbCandidate | null { + const org = String(row.OrgNr ?? '').replace(/[^0-9]/g, '') + const legalFormCode = String(row['Juridisk form, kod'] ?? '').trim() + if (org.length !== 10 || NON_COMPANY_LEGAL_FORMS.has(legalFormCode)) return null + const str = (k: string) => { + const v = row[k] + const t = v === null || v === undefined ? '' : String(v).trim() + return t === '' ? null : t + } + return { + orgNumber: org, + name: str('Företagsnamn') ?? org, + city: str('PostOrt'), + industry: str('Bransch_1'), + legalForm: str('Juridisk form'), + status: str('Företagsstatus'), + active: String(row['Företagsstatus, kod'] ?? '').trim() === '1', + } +} + +export function identityLookupBody(orgNumber10: string) { + return { + Variabler: [{ Variabel: SCB_ORG_VARIABLE, Operator: 'ArLikaMed', Varde1: orgNumber10, Varde2: '' }], + Kategorier: [], + } +} + +export function createScbClient(config: ScbConfig, deps: { json?: typeof scbJson } = {}): ScbClient { + const json = deps.json ?? scbJson + return { + variables: () => json(config, 'GET', '/api/Je/Variabler'), + categories: () => json(config, 'GET', '/api/Je/KategorierMedKodtabeller'), + async lookupByOrgNumber(orgNumber) { + if (!isLegalPersonOrgNumber(orgNumber)) { + throw new Error('SCB-uppslag görs bara på organisationsnummer för juridiska personer.') + } + const org10 = orgNumber.replace(/[^0-9]/g, '') + const peOrgNr = toPeOrgNr(org10) + const fetchedAt = new Date().toISOString() + const rows = await json(config, 'POST', '/api/Je/HamtaForetag', identityLookupBody(org10)) + const list = Array.isArray(rows) ? rows : [] + const row = list.find((r) => String(r.OrgNr ?? r.PeOrgNr ?? '').replace(/[^0-9]/g, '').endsWith(org10)) ?? null + return { found: Boolean(row), peOrgNr, row, facts: row ? factsFromScbCompany(row) : [], fetchedAt } + }, + async searchByName(raw) { + const query = nameQuery(raw) + if (query.length < 2) return { query, mode: 'starts_with', total: 0, truncated: false, candidates: [] } + // Count first: a short word can match thousands and we never pull those. + const run = async (mode: 'starts_with' | 'contains'): Promise => { + const body = nameSearchBody(query, mode) + const total = Number(await json(config, 'POST', '/api/Je/RaknaForetag', body)) || 0 + if (total === 0) return { query, mode, total, truncated: false, candidates: [] } + if (total > SCB_SEARCH_CAP * 4) return { query, mode, total, truncated: true, candidates: [] } + const rows = await json(config, 'POST', '/api/Je/HamtaForetag', body) + const all = (Array.isArray(rows) ? rows : []).map(candidateFrom).filter((c): c is ScbCandidate => c !== null) + // Active companies first, then by name; the cap keeps the picker a picker. + all.sort((a, b) => Number(b.active) - Number(a.active) || a.name.localeCompare(b.name, 'sv')) + // total is what the picker can offer: SCB's count minus the natural + // persons and estates we never show ("Eismann" counted 1, offered 0). + return { query, mode, total: all.length, truncated: all.length > SCB_SEARCH_CAP, candidates: all.slice(0, SCB_SEARCH_CAP) } + } + const first = await run('starts_with') + if (first.total > 0 || first.truncated || query.length < 4) return first + return run('contains') + }, + } +} diff --git a/lib/parties/scb/config.ts b/lib/parties/scb/config.ts new file mode 100644 index 00000000..4c51e30a --- /dev/null +++ b/lib/parties/scb/config.ts @@ -0,0 +1,41 @@ +/** + * SCB:s allmänna företagsregister, the free API (SokPaVar layout Je). + * + * Access is a client certificate SCB issues per user plus its password; both + * live in env vars only, never in settings or the repository: + * SCB_API_CERT_PFX_BASE64 the .pfx SCB mailed, base64-encoded + * SCB_API_CERT_PASSWORD the password SCB mailed separately + * SCB_API_BASE_URL optional override (default: the current API) + * + * SCB replaces this API with an API-key one from September 2026 and keeps + * the current one for a transition period; everything that knows the wire + * format sits in client.ts so the swap is one file. + */ + +export interface ScbConfig { + baseUrl: string + pfx: Buffer + passphrase: string + timeoutMs: number +} + +export const SCB_DEFAULT_BASE_URL = 'https://privateapi.scb.se/nv0101/v1/sokpavar' + +/** True when the hosted environment carries SCB credentials: gates the button. */ +export function isScbConfigured(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.SCB_API_CERT_PFX_BASE64 && env.SCB_API_CERT_PASSWORD) +} + +export function scbConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ScbConfig { + const pfx = env.SCB_API_CERT_PFX_BASE64 + const passphrase = env.SCB_API_CERT_PASSWORD + if (!pfx || !passphrase) { + throw new Error('SCB är inte konfigurerat: SCB_API_CERT_PFX_BASE64 och SCB_API_CERT_PASSWORD saknas.') + } + return { + baseUrl: (env.SCB_API_BASE_URL ?? SCB_DEFAULT_BASE_URL).replace(/\/+$/, ''), + pfx: Buffer.from(pfx, 'base64'), + passphrase, + timeoutMs: 20_000, + } +} diff --git a/lib/parties/scb/map.ts b/lib/parties/scb/map.ts new file mode 100644 index 00000000..ffcc21bf --- /dev/null +++ b/lib/parties/scb/map.ts @@ -0,0 +1,265 @@ +/** + * From SCB's Je layout to party facts. Codes come from "Variabelbeskrivning + * API" (SCB:s allmänna företagsregister, 28 pages, saved in + * dev_docs/scb_docs); labels are Swedish because the dossier shows them as + * they are and the register is Swedish by nature. + */ + +export interface ScbFact { + field: string + value: unknown + reference?: Record + valid_from?: string +} + +const F_SKATT: Record = { + '0': 'Har aldrig varit registrerat för F-skatt', + '1': 'Godkänd för F-skatt', + '9': 'Avregistrerad för F-skatt', +} +const MOMS: Record = { + '0': 'Har aldrig varit registrerat för moms', + '1': 'Momsregistrerad', + '3': 'Momsregistrerad via representant', + '9': 'Avregistrerad för moms', +} +const ARBETSGIVARE: Record = { + '0': 'Har aldrig varit registrerad som arbetsgivare', + '1': 'Registrerad som arbetsgivare', + '2': 'Registrerad som privatarbetsgivare', + '3': 'Registrerad som arbetsgivare via representant', + '4': 'Registrerad som ambassad eller konsulat', + '9': 'Avregistrerad som arbetsgivare', +} +const FORETAGSSTATUS: Record = { + '0': 'Har aldrig varit verksamt', + '1': 'Verksamt', + '9': 'Ej verksamt', +} +export const JURIDISK_FORM: Record = { + '10': 'Fysisk person', + '21': 'Enkelt bolag', + '22': 'Partrederi', + '23': 'Värdepappersfond', + '31': 'Handelsbolag eller kommanditbolag', + '32': 'Gruvbolag', + '41': 'Bankaktiebolag', + '42': 'Försäkringsaktiebolag', + '43': 'Europabolag', + '49': 'Aktiebolag', + '51': 'Ekonomisk förening', + '53': 'Bostadsrättsförening', + '54': 'Kooperativ hyresrättsförening', + '55': 'Europakooperativ', + '61': 'Ideell förening', + '62': 'Samfällighet', + '63': 'Registrerat trossamfund', + '71': 'Familjestiftelse', + '72': 'Stiftelse eller fond', + '81': 'Statlig enhet', + '82': 'Kommun', + '83': 'Kommunalförbund', + '84': 'Region', + '85': 'Allmän försäkringskassa', + '87': 'Offentlig korporation eller anstalt', + '88': 'Hypoteksförening', + '89': 'Regional statlig myndighet', + '91': 'Oskiftat dödsbo', + '92': 'Ömsesidigt försäkringsbolag', + '93': 'Sparbank', + '94': 'Understöds- eller försäkringsförening', + '95': 'Arbetslöshetskassa', + '96': 'Utländsk juridisk person', + '98': 'Övrig svensk juridisk person', + '99': 'Juridisk form ej utredd', +} +const BOLAGSVERKET_STATUS: Record = { + '0': 'Normalläge', + '11': 'Ackordsförhandling inledd', + '12': 'Ackordsförhandling upphör', + '13': 'Ackordsförhandling upphävd av domstol', + '20': 'Konkurs inledd', + '21': 'Konkurs avslutad', + '22': 'Konkurs avslutad med överskott', + '24': 'Konkurs upphävd av rätt', + '31': 'Likvidation avslutad', + '32': 'Likvidation beslutad', + '33': 'Likvidation fortsätter', + '34': 'Likvidation upphör, verksamheten återupptas', + '35': 'Likvidation upphävd av domstol', + '36': 'Bolaget avfört enligt 13 kap 18 § ABL', + '37': 'Bolaget är avfört', + '40': 'Fusion inledd', + '41': 'Upplöst genom fusion', + '45': 'Fusion tillåten', + '49': 'Fusion pågår', + '50': 'Avförd enligt 17 § handelsregisterlagen', + '51': 'Avförd', + '52': 'Avregistrerad', + '53': 'Avregistrerad på grund av ny innehavare', + '54': 'Avförd på grund av fusion med utländskt företag', + '60': 'Avförd på grund av utländskt företags likvidation eller konkurs', + '61': 'Avförd, verksamheten har upphört', + '62': 'Avförd, filialen saknar verkställande direktör', + '63': 'Avförd enligt domstolsbeslut', + '64': 'Avförd, årsredovisning saknas', + '70': 'Bolaget avfört på egen begäran', + '71': 'Bolaget avfört av Bolagsverket', + '73': 'Avförd', + '74': 'Avförd, omregistrerat till bankaktiebolag', + '75': 'Beslut om ombildning', + '76': 'Tillstånd till ombildning', + '77': 'Avregistrerad på grund av ombildning', + '78': 'Ombildning förfallen', + '80': 'Företagsrekonstruktion inledd', + '81': 'Företagsrekonstruktion upphörd', + '82': 'Företagsrekonstruktion upphävd av domstol', + '85': 'Resolution inledd', + '86': 'Resolution avslutad', + '87': 'Resolution upphävd', + '90': 'Delning pågår', + '91': 'Upplöst genom delning', + '99': 'Övertagande av annat bolag pågår', +} +const STORLEKSKLASS: Record = { + '0': 'Uppgift saknas', + '1': '0 anställda', + '2': '1 till 4 anställda', + '3': '5 till 9 anställda', + '4': '10 till 19 anställda', + '5': '20 till 49 anställda', + '6': '50 till 99 anställda', + '7': '100 till 199 anställda', + '8': '200 till 499 anställda', + '9': '500 till 999 anställda', + '10': '1 000 till 1 499 anställda', + '11': '1 500 till 1 999 anställda', + '12': '2 000 till 2 999 anställda', + '13': '3 000 till 3 999 anställda', + '14': '4 000 till 4 999 anställda', + '15': '5 000 till 9 999 anställda', + '16': '10 000 anställda eller fler', +} + +/** + * Which Bolagsverket statuses mean "do not treat this as a going concern": + * distress (ackord, konkurs, likvidation, rekonstruktion, resolution) and + * disappearance (avförd, avregistrerad, upplöst). A fusion or delning in + * progress is not a warning: Fortnox AB and Avanza Bank carried "Fusion + * pågår" on 2026-09-03 while trading normally. + */ +export const BOLAGSVERKET_WARNING_CODES = new Set(['11', '12', '13', '20', '32', '33', '36', '37', '41', '50', '51', '52', '53', '54', '60', '61', '62', '63', '64', '70', '71', '73', '77', '80', '85', '91']) + +/** + * A company row as the API returns it (fixture: __tests__/fixtures/ + * volvo-je.json, fetched live 2026-09-03). Codes come as ", kod" and + * SCB's own text as ""; values are space-padded. Lookups are tolerant + * of case, spacing and diacritics so a renamed column in the new API still + * maps, and SCB's text wins over our label table when both exist. + */ +export type ScbCompanyRow = Record + +function pick(row: ScbCompanyRow, ...names: string[]): string | null { + const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '') + const wanted = new Set(names.map(norm)) + for (const [k, v] of Object.entries(row)) { + if (wanted.has(norm(k))) { + if (v === null || v === undefined) return null + const s = String(v).trim() + return s === '' ? null : s + } + } + return null +} + +function coded(field: string, code: string | null, table: Record, extra: Record = {}, text: string | null = null): ScbFact | null { + if (code === null) return null + const c = code.replace(/^0+(?=\d)/, '') || '0' + return { field, value: { code: c, label: text ?? table[c] ?? `Kod ${c}`, ...extra } } +} + +function isoDate(s: string | null): string | null { + if (!s) return null + const d = s.replace(/[^0-9]/g, '') + if (d.length === 8) return `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` + if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10) + return null +} + +/** Map one Je row to facts. Unknown or empty variables simply produce nothing. */ +export function factsFromScbCompany(row: ScbCompanyRow): ScbFact[] { + const out: ScbFact[] = [] + const push = (f: ScbFact | null) => { + if (f) out.push(f) + } + + const name = pick(row, 'Företagsnamn', 'Foretagsnamn', 'Namn') + if (name) push({ field: 'legal_name', value: name }) + const firma = pick(row, 'Firma') + if (firma && firma !== name) push({ field: 'trade_name', value: firma }) + + // ", kod" carries the code, "" SCB's text. A column without + // the ", kod" twin (older layouts) is a bare code. + const codeOf = (...names: string[]) => pick(row, ...names.map((n) => `${n}, kod`), ...names) + const textOf = (...names: string[]) => { + const t = pick(row, ...names) + return t && !/^\d+$/.test(t) ? t : null + } + push(coded('f_tax', codeOf('Fskattstatus', 'F-skattstatus'), F_SKATT, {}, textOf('Fskattstatus', 'F-skattstatus'))) + push(coded('vat_registration', codeOf('Momsstatus'), MOMS, {}, textOf('Momsstatus'))) + push(coded('employer_registration', codeOf('Arbetsgivarstatus'), ARBETSGIVARE, {}, textOf('Arbetsgivarstatus'))) + push(coded('company_status', codeOf('Företagsstatus', 'Foretagsstatus'), FORETAGSSTATUS, {}, textOf('Företagsstatus', 'Foretagsstatus'))) + push(coded('legal_form', codeOf('Juridisk form'), JURIDISK_FORM, {}, textOf('Juridisk form'))) + const bv = codeOf('Bolagsstatus', 'Status hos Bolagsverket') + push(coded('bolagsverket_status', bv, BOLAGSVERKET_STATUS, bv ? { warning: BOLAGSVERKET_WARNING_CODES.has(bv.replace(/^0+(?=\d)/, '') || '0') } : {}, textOf('Bolagsstatus', 'Status hos Bolagsverket'))) + push(coded('employees_band', codeOf('Stkl', 'Storleksklass Anställda'), STORLEKSKLASS, {}, textOf('Storleksklass', 'Storleksklass Anställda'))) + + // A Swedish company registered for moms has VAT number SE + org number + 01 + // by construction (Skatteverket assigns no other form), so the registry's + // moms flag gives the number itself. Represented as a fact so the source + // and date travel with it; the supplier row copies it on promotion. + const momsCode = codeOf('Momsstatus') + const org = pick(row, 'OrgNr') + if ((momsCode === '1' || momsCode === '3') && org && /^\d{10}$/.test(org)) { + push({ field: 'vat_number', value: `SE${org}01` }) + } + + const sni = pick(row, 'Bransch_1, kod', 'Bransch_1', 'Bransch', 'SNI') + const sniText = textOf('Bransch_1', 'Bransch_1, text', 'Bransch') + if (sni && /^\d/.test(sni)) push({ field: 'industry', value: { code: sni, label: sniText } }) + + const street = pick(row, 'PostAdress', 'Postadress') + const postal = pick(row, 'PostNr', 'Postnr', 'Postnummer') + const city = pick(row, 'PostOrt', 'Postort') + const co = pick(row, 'COAdress', 'COadress', 'C/O-adress') + if (street || postal || city) push({ field: 'postal_address', value: { street, co, postal_code: postal, city } }) + + const municipality = pick(row, 'Säteskommun, kod', 'Sateskommun, kod') + const county = pick(row, 'Säteslän, kod', 'Sateslan, kod') + const municipalityName = textOf('Säteskommun', 'Sateskommun') + const countyName = textOf('Säteslän', 'Sateslan') + if (municipality || county || municipalityName) { + push({ field: 'seat', value: { municipality_code: municipality, county_code: county, municipality: municipalityName, county: countyName } }) + } + + const turnoverYear = pick(row, 'Omsättning, år', 'Omsattning, ar') + const turnoverBand = textOf('Storleksklass, oms') + const turnoverCode = codeOf('Stkl, oms') + if (turnoverBand || turnoverCode) push({ field: 'turnover_band', value: { code: turnoverCode, label: turnoverBand, year: turnoverYear } }) + + const registered = isoDate(pick(row, 'Registreringsdatum')) + if (registered) push({ field: 'registered_at', value: registered }) + const started = isoDate(pick(row, 'Startdatum')) + if (started) push({ field: 'active_since', value: started }) + const ended = isoDate(pick(row, 'Slutdatum')) + if (ended) push({ field: 'active_until', value: ended }) + + const phone = pick(row, 'Telefon') + if (phone) push({ field: 'phone', value: phone }) + const email = pick(row, 'E-post', 'Epost', 'E-postadress') + if (email) push({ field: 'email', value: email }) + const workplaces = pick(row, 'Antal arbetsställen', 'AntalArbetsstallen', 'Antal arbetsstallen') + if (workplaces && /^\d+$/.test(workplaces)) push({ field: 'workplaces', value: Number(workplaces) }) + + return out +} diff --git a/lib/parties/scb/org-number.ts b/lib/parties/scb/org-number.ts new file mode 100644 index 00000000..804b90c0 --- /dev/null +++ b/lib/parties/scb/org-number.ts @@ -0,0 +1,22 @@ +/** + * Which numbers we send to SCB. + * + * A Swedish organisationsnummer for a juridisk person has 20 or more in + * positions 3 and 4 (the "month" slot), which is how it is told apart from a + * personnummer. A sole trader's org number IS a personnummer, so a lookup + * would be personal-data processing with SCB as an independent controller; + * the plan keeps sole traders out of registry enrichment in this phase (no + * credit or registry facts on natural persons, GDPR Art. 14 notice first). + */ +export function isLegalPersonOrgNumber(orgNumber: string | null | undefined): boolean { + const d = (orgNumber ?? '').replace(/[^0-9]/g, '') + if (d.length !== 10) return false + const month = Number(d.slice(2, 4)) + return month >= 20 +} + +/** SCB's PeOrgNr: 16 + the ten-digit org number for legal persons. */ +export function toPeOrgNr(orgNumber: string): string { + const d = orgNumber.replace(/[^0-9]/g, '') + return d.length === 10 ? `16${d}` : d +} diff --git a/lib/parties/scb/transport.ts b/lib/parties/scb/transport.ts new file mode 100644 index 00000000..e33aa3e0 --- /dev/null +++ b/lib/parties/scb/transport.ts @@ -0,0 +1,89 @@ +import { request as httpsRequest, type RequestOptions } from 'node:https' +import type { ScbConfig } from './config' + +export class ScbApiError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly body: string, + ) { + super(message) + this.name = 'ScbApiError' + } +} + +export interface ScbHttpResponse { + status: number + body: string +} + +/** + * One HTTPS request with the client certificate. node:https rather than + * fetch: undici's fetch has no portable client-certificate option inside a + * Next.js route (same reason the Bolagsverket client does this). + */ +export function scbRequest(config: ScbConfig, method: 'GET' | 'POST', path: string, jsonBody?: unknown): Promise { + return new Promise((resolve, reject) => { + const url = new URL(config.baseUrl + path) + const payload = jsonBody === undefined ? null : JSON.stringify(jsonBody) + const options: RequestOptions = { + method, + hostname: url.hostname, + path: url.pathname + url.search, + headers: { + Accept: 'application/json', + ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}), + }, + pfx: config.pfx, + passphrase: config.passphrase, + timeout: config.timeoutMs, + } + const req = httpsRequest(options, (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })) + }) + req.on('timeout', () => req.destroy(new Error('SCB svarade inte i tid'))) + req.on('error', (err) => reject(err)) + if (payload) req.write(payload) + req.end() + }) +} + +const RETRIABLE = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE', 'EAI_AGAIN']) + +function isTransient(err: unknown): boolean { + const e = err as { code?: string; message?: string } | null + return Boolean(e && ((e.code && RETRIABLE.has(e.code)) || /svarade inte i tid|socket hang up/i.test(e.message ?? ''))) +} + +/** + * One JSON call with a single retry on a dropped connection: SCB resets + * the TLS session now and then (seen live 2026-09-03), and every request + * here is idempotent (counts, lists, lookups). + */ +export async function scbJson( + config: ScbConfig, + method: 'GET' | 'POST', + path: string, + jsonBody?: unknown, + deps: { request?: typeof scbRequest; delayMs?: number } = {}, +): Promise { + const request = deps.request ?? scbRequest + let res: ScbHttpResponse + try { + res = await request(config, method, path, jsonBody) + } catch (err) { + if (!isTransient(err)) throw err + await new Promise((r) => setTimeout(r, deps.delayMs ?? 400)) + res = await request(config, method, path, jsonBody) + } + if (res.status < 200 || res.status >= 300) { + throw new ScbApiError(`SCB svarade ${res.status} på ${method} ${path}`, res.status, res.body.slice(0, 2000)) + } + try { + return JSON.parse(res.body) as T + } catch { + throw new ScbApiError(`SCB svarade med något annat än JSON på ${method} ${path}`, res.status, res.body.slice(0, 2000)) + } +} diff --git a/messages/en.json b/messages/en.json index 71881c51..89519307 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8347,6 +8347,52 @@ "action_failed": "That did not work. Try again.", "viewer_disabled_tooltip": "You have read access in this company.", "count_summary": "{count} suggestions", + "fetch_registry": "Fetch details", + "fetching_registry": "Fetching…", + "registry_fetched_title": "Details fetched from SCB", + "registry_fetched_description": "{inserted} new, {superseded} changed, {refreshed} unchanged.", + "registry_not_found_title": "Not in the business register", + "registry_not_found_description": "SCB has no active record for {org}.", + "registry_unavailable_title": "SCB did not answer", + "registry_sole_trader": "Not fetched for sole traders.", + "source_scb": "SCB", + "source_document": "documents", + "source_user": "you", + "fact_f_tax": "F-tax", + "fact_vat_registration": "VAT", + "fact_employer_registration": "Employer", + "fact_company_status": "Status", + "fact_legal_form": "Legal form", + "fact_bolagsverket_status": "Bolagsverket", + "fact_employees_band": "Employees", + "fact_industry": "Industry", + "fact_postal_address": "Postal address", + "fact_seat": "Seat", + "fact_registered_at": "Registered", + "fact_active_since": "Active since", + "fact_active_until": "Active until", + "fact_phone": "Phone", + "fact_email": "Email", + "fact_workplaces": "Workplaces", + "fact_turnover_band": "Turnover", + "fact_registered_skv": "Skatteverket", + "fact_postal_code_city": "Postal code and city", + "more_actions": "More actions", + "registry_group": "From SCB · fetched {date}", + "pick_registry": "Find in the business register", + "picker_title": "Which company do you mean?", + "picker_body": "SCB searches for {name}. Pick the right company and its org number is saved on the contact and the details are fetched.", + "picker_found": "SCB finds {count} companies like {query}. Which one do you mean?", + "picker_none": "SCB finds no company like {query}. Try another name.", + "picker_too_many": "SCB finds {count} companies for {query}. Type more of the name.", + "picker_search_placeholder": "Search by another name", + "picker_inactive": "Not active", + "picker_confirm": "Choose {name}", + "picker_confirm_empty": "Choose a company", + "picker_taken_title": "Org number already on {name}", + "picker_taken_description": "Merge the two instead of choosing the same company twice.", + "reason_org_picked": "Org number {org} chosen in the business register", + "fact_trade_name": "Trade name", "open_dossier": "Open {name}" } } diff --git a/messages/sv.json b/messages/sv.json index e7d05c27..a4879647 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -8347,6 +8347,52 @@ "action_failed": "Det gick inte. Försök igen.", "viewer_disabled_tooltip": "Du har läsbehörighet i det här bolaget.", "count_summary": "{count} förslag", + "fetch_registry": "Hämta uppgifter", + "fetching_registry": "Hämtar…", + "registry_fetched_title": "Uppgifter hämtade från SCB", + "registry_fetched_description": "{inserted} nya, {superseded} ändrade, {refreshed} oförändrade.", + "registry_not_found_title": "Inte i företagsregistret", + "registry_not_found_description": "SCB har ingen aktiv post för {org}.", + "registry_unavailable_title": "SCB svarade inte", + "registry_sole_trader": "Hämtas inte för enskilda firmor.", + "source_scb": "SCB", + "source_document": "underlag", + "source_user": "du", + "fact_f_tax": "F-skatt", + "fact_vat_registration": "Moms", + "fact_employer_registration": "Arbetsgivare", + "fact_company_status": "Status", + "fact_legal_form": "Bolagsform", + "fact_bolagsverket_status": "Bolagsverket", + "fact_employees_band": "Anställda", + "fact_industry": "Bransch", + "fact_postal_address": "Postadress", + "fact_seat": "Säte", + "fact_registered_at": "Registrerat", + "fact_active_since": "Verksamt sedan", + "fact_active_until": "Verksamt till", + "fact_phone": "Telefon", + "fact_email": "E-post", + "fact_workplaces": "Arbetsställen", + "fact_turnover_band": "Omsättning", + "fact_registered_skv": "Skatteverket", + "fact_postal_code_city": "Postnummer och ort", + "more_actions": "Fler åtgärder", + "registry_group": "Från SCB · hämtat {date}", + "pick_registry": "Hitta i företagsregistret", + "picker_title": "Vilket företag menar du?", + "picker_body": "SCB söker på {name}. Välj rätt företag så sparas org.nr på kontakten och uppgifterna hämtas.", + "picker_found": "SCB hittar {count} företag som liknar {query}. Vilket menar du?", + "picker_none": "SCB hittar inget företag som liknar {query}. Prova ett annat namn.", + "picker_too_many": "SCB hittar {count} företag på {query}. Skriv mer av namnet.", + "picker_search_placeholder": "Sök på annat namn", + "picker_inactive": "Ej verksamt", + "picker_confirm": "Välj {name}", + "picker_confirm_empty": "Välj ett företag", + "picker_taken_title": "Org.nr finns redan på {name}", + "picker_taken_description": "Slå ihop de två i stället för att välja samma företag två gånger.", + "reason_org_picked": "Org.nr {org} valt i företagsregistret", + "fact_trade_name": "Firma", "open_dossier": "Öppna {name}" } } diff --git a/scripts/scb/discover.ts b/scripts/scb/discover.ts new file mode 100644 index 00000000..d87cea5a --- /dev/null +++ b/scripts/scb/discover.ts @@ -0,0 +1,44 @@ +/** + * Print what SCB's current API exposes on the Je layout: the variable list, + * the category code tables, and one company looked up by org number, so the + * names in lib/parties/scb/client.ts are checked against the live API. + * + * Usage: + * SCB_API_CERT_PFX_BASE64=... SCB_API_CERT_PASSWORD=... \ + * npx tsx scripts/scb/discover.ts [--org 5560125790] [--env ] + * + * Read-only against SCB. Prints to stdout; never writes to a database. + */ +import { config as dotenv } from 'dotenv' +import { createScbClient } from '@/lib/parties/scb/client' +import { scbConfigFromEnv } from '@/lib/parties/scb/config' + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : undefined +} + +async function main() { + const envFile = arg('env') + if (envFile) dotenv({ path: envFile }) + const client = createScbClient(scbConfigFromEnv()) + const org = arg('org') ?? '5560125790' + const show = (label: string, v: unknown) => console.log(`\n=== ${label}\n${JSON.stringify(v, null, 2).slice(0, 12000)}`) + try { + show('Variabler (Je)', await client.variables()) + } catch (e) { + console.error('Variabler failed:', e instanceof Error ? e.message : e) + } + try { + show('KategorierMedKodtabeller (Je)', await client.categories()) + } catch (e) { + console.error('Kategorier failed:', e instanceof Error ? e.message : e) + } + try { + show(`HamtaForetag ${org}`, await client.lookupByOrgNumber(org)) + } catch (e) { + console.error('HamtaForetag failed:', e instanceof Error ? e.message : e) + } +} + +void main() diff --git a/supabase/migrations/20260904000200_record_party_facts.sql b/supabase/migrations/20260904000200_record_party_facts.sql new file mode 100644 index 00000000..709ba4f7 --- /dev/null +++ b/supabase/migrations/20260904000200_record_party_facts.sql @@ -0,0 +1,89 @@ +-- Parties, phase 3 start: one way to write facts from a registry. +-- +-- record_party_facts(company, user, party, source, facts, fetched_at) +-- facts: [{field, value, reference?, valid_from?, valid_to?}] +-- +-- Facts are statements with provenance and time (plan section 05). A new +-- statement for the same field from the same source supersedes the old one +-- only when the value changed: an unchanged value just refreshes fetched_at, +-- so "SCB · 2026-09-03" on the dossier means "checked then", not "changed +-- then". Facts from other sources (document, user) are never touched: the +-- dossier shows every source side by side and the survivorship chain +-- (user > registry > document > bank > ledger > model) decides what leads. + +CREATE OR REPLACE FUNCTION public.record_party_facts( + p_company_id uuid, + p_user_id uuid, + p_party_id uuid, + p_source text, + p_facts jsonb, + p_fetched_at timestamptz DEFAULT now() +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path TO 'public' +AS $$ +DECLARE + v_fact jsonb; + v_field text; + v_existing_id uuid; + v_existing_value jsonb; + v_inserted integer := 0; + v_superseded integer := 0; + v_refreshed integer := 0; +BEGIN + IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN + RAISE EXCEPTION 'record_party_facts: p_user_id must be the caller' USING ERRCODE = '42501'; + END IF; + IF p_source NOT IN ('user', 'registry_scb', 'registry_tic', 'vies', 'peppol', 'document', 'bank', 'ledger', 'model') THEN + RAISE EXCEPTION 'record_party_facts: unknown source %', p_source USING ERRCODE = '22023'; + END IF; + IF p_facts IS NULL OR jsonb_typeof(p_facts) <> 'array' THEN + RAISE EXCEPTION 'record_party_facts: p_facts must be a JSON array' USING ERRCODE = '22023'; + END IF; + IF NOT EXISTS (SELECT 1 FROM public.parties p WHERE p.id = p_party_id AND p.company_id = p_company_id AND p.merged_into IS NULL) THEN + RAISE EXCEPTION 'record_party_facts: party % is not a live party of this company', p_party_id USING ERRCODE = '23503'; + END IF; + + FOR v_fact IN SELECT * FROM jsonb_array_elements(p_facts) LOOP + v_field := nullif(btrim(coalesce(v_fact->>'field', '')), ''); + IF v_field IS NULL OR length(v_field) > 64 THEN + RAISE EXCEPTION 'record_party_facts: every fact needs a field of at most 64 characters' USING ERRCODE = '22023'; + END IF; + + SELECT f.id, f.value INTO v_existing_id, v_existing_value + FROM public.party_facts f + WHERE f.party_id = p_party_id AND f.company_id = p_company_id AND f.field = v_field AND f.source = p_source AND f.superseded_at IS NULL + ORDER BY f.recorded_at DESC LIMIT 1; + + IF v_existing_id IS NOT NULL AND v_existing_value = v_fact->'value' THEN + UPDATE public.party_facts SET fetched_at = p_fetched_at, reference = coalesce(v_fact->'reference', reference) + WHERE id = v_existing_id; + v_refreshed := v_refreshed + 1; + CONTINUE; + END IF; + + IF v_existing_id IS NOT NULL THEN + UPDATE public.party_facts SET superseded_at = p_fetched_at WHERE id = v_existing_id; + v_superseded := v_superseded + 1; + END IF; + + INSERT INTO public.party_facts (party_id, company_id, user_id, field, value, source, reference, fetched_at, valid_from, valid_to, recorded_at) + VALUES ( + p_party_id, p_company_id, p_user_id, v_field, v_fact->'value', p_source, v_fact->'reference', p_fetched_at, + (v_fact->>'valid_from')::date, (v_fact->>'valid_to')::date, p_fetched_at + ); + v_inserted := v_inserted + 1; + END LOOP; + + RETURN jsonb_build_object('inserted', v_inserted, 'superseded', v_superseded, 'refreshed', v_refreshed); +END; +$$; + +REVOKE ALL ON FUNCTION public.record_party_facts(uuid, uuid, uuid, text, jsonb, timestamptz) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.record_party_facts(uuid, uuid, uuid, text, jsonb, timestamptz) TO authenticated, service_role; +COMMENT ON FUNCTION public.record_party_facts(uuid, uuid, uuid, text, jsonb, timestamptz) IS + 'Records facts from one source for one party: unchanged values refresh fetched_at, changed values supersede the previous statement and insert a new one. Other sources are never touched.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/record-party-facts.pg.test.ts b/tests/pg/record-party-facts.pg.test.ts new file mode 100644 index 00000000..4608f76b --- /dev/null +++ b/tests/pg/record-party-facts.pg.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { getPool, withUserContext } from './setup' +import { seedCompany } from './fixtures' + +async function party(companyId: string, userId: string): Promise { + const { rows } = await getPool().query<{ id: string }>( + `INSERT INTO public.parties (company_id, user_id, display_name, org_number) VALUES ($1, $2, 'Beijer Byggmaterial AB', '5564300142') RETURNING id`, + [companyId, userId], + ) + return rows[0]!.id +} + +async function record(companyId: string, userId: string, partyId: string, source: string, facts: unknown[], fetchedAt = '2026-09-03T10:00:00Z') { + const { rows } = await getPool().query<{ r: Record }>( + `SELECT public.record_party_facts($1, $2, $3, $4, $5::jsonb, $6::timestamptz) AS r`, + [companyId, userId, partyId, source, JSON.stringify(facts), fetchedAt], + ) + return rows[0]!.r +} + +describe('record_party_facts (pg)', () => { + it('inserts, refreshes unchanged values, supersedes changed ones, and leaves other sources alone', async () => { + const c = await seedCompany() + const id = await party(c.companyId, c.userId) + await getPool().query( + `INSERT INTO public.party_facts (party_id, company_id, user_id, field, value, source) VALUES ($1, $2, $3, 'legal_name', '"Beijer Bygg"'::jsonb, 'document')`, + [id, c.companyId, c.userId], + ) + const first = await record(c.companyId, c.userId, id, 'registry_scb', [ + { field: 'legal_name', value: 'Beijer Byggmaterial AB', reference: { layout: 'Je' } }, + { field: 'f_tax', value: { code: '1', label: 'Godkänd för F-skatt' } }, + ]) + expect(first).toEqual({ inserted: 2, superseded: 0, refreshed: 0 }) + + const second = await record(c.companyId, c.userId, id, 'registry_scb', [ + { field: 'legal_name', value: 'Beijer Byggmaterial AB' }, + { field: 'f_tax', value: { code: '9', label: 'Avregistrerad för F-skatt' } }, + ], '2026-09-10T10:00:00Z') + expect(second).toEqual({ inserted: 1, superseded: 1, refreshed: 1 }) + + const rows = await getPool().query<{ field: string; source: string; value: unknown; fetched: string | null; superseded: boolean }>( + `SELECT field, source, value, fetched_at::text AS fetched, superseded_at IS NOT NULL AS superseded + FROM public.party_facts WHERE party_id = $1 ORDER BY source, field, recorded_at`, + [id], + ) + const scb = rows.rows.filter((r) => r.source === 'registry_scb') + expect(scb.map((r) => [r.field, r.superseded])).toEqual([ + ['f_tax', true], + ['f_tax', false], + ['legal_name', false], + ]) + const live = scb.find((r) => r.field === 'legal_name')! + expect(live.fetched?.startsWith('2026-09-10')).toBe(true) + expect(rows.rows.filter((r) => r.source === 'document')).toHaveLength(1) + expect(rows.rows.find((r) => r.source === 'document')!.superseded).toBe(false) + }) + + it('refuses another company, a merged party, an unknown source, an empty field, and a spoofed user', async () => { + const mine = await seedCompany() + const theirs = await seedCompany() + const id = await party(mine.companyId, mine.userId) + await expect(record(theirs.companyId, theirs.userId, id, 'registry_scb', [{ field: 'x', value: 1 }])).rejects.toMatchObject({ code: '23503' }) + await expect(record(mine.companyId, mine.userId, id, 'gossip', [{ field: 'x', value: 1 }])).rejects.toMatchObject({ code: '22023' }) + await expect(record(mine.companyId, mine.userId, id, 'registry_scb', [{ field: '', value: 1 }])).rejects.toMatchObject({ code: '22023' }) + await expect( + withUserContext(mine.userId, (client) => + client.query(`SELECT public.record_party_facts($1, $2, $3, 'registry_scb', '[]'::jsonb)`, [mine.companyId, theirs.userId, id]), + ), + ).rejects.toMatchObject({ code: '42501' }) + const other = await party(mine.companyId, mine.userId).catch(() => null) + if (other) { + await getPool().query(`UPDATE public.parties SET merged_into = $2, archived_at = now() WHERE id = $1`, [other, id]) + await expect(record(mine.companyId, mine.userId, other, 'registry_scb', [{ field: 'x', value: 1 }])).rejects.toMatchObject({ code: '23503' }) + } + }) +})