diff --git a/DECISIONS.md b/DECISIONS.md index e6fc90ae..f315cbfa 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1684,6 +1684,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-08] Issue #2224 follow-up from the correctness skeptic: the quote decision (open/declined) is now locked in the database while a live kundorder exists (migration 20260908165100 extends invoices_quote_decision_guard), reversing the earlier call to leave it open; a declined offert behind a confirmed, invoiced order was a contradictory agreement trail and the dashboard hid the re-accept button, so the quote was stuck. The three source and decision guards run as SECURITY DEFINER: a SELECT FOR UPDATE under RLS admits only the caller's active company, so a multi-company member writing for another company through raw PostgREST got no row, no lock and no guard. Both landed as a second migration rather than an edit of 20260908165000, which was already applied to staging under that version. [2026-09-08] Draft invoice PDF marks a draft with one diagonal, faint word (UTKAST / DRAFT) across every page instead of a banner in the top margin (#2437): a banner reads as UI chrome on a document, a watermark reads as a stamp and leaves the preview pixel-identical to the final print. The long legal sentence (saknar löpnummer, ML 17 kap 24 §) is dropped on purpose: the word alone says the document is not a valid invoice, and the download dialog (#2399) already explains why before the file exists. Rotation and opacity sit on a padded wrapper View so the word turns about its own centre. Skeptic refutation accepted: the first cut (#6b7280 at 0.14, about 92% brightness) would drop out of a monochrome print or greyscale scan, and a numbered draft otherwise prints title, number and OCR like a real faktura; now #4b5563 at 0.3 (about 79% brightness), with a test pinning the composited grey between 70% and 85%. A 1-bit scan can still threshold the word away; a second explicit line on numbered drafts was left out because the request was the word alone, and that residual is Emil's call. Second refutation accepted: the overlay is emitted as the LAST child of the Page, because react-pdf paints in document order and `fixed` does not hoist, so an overlay emitted first was painted under the opaque payment and customer boxes and the word vanished on the page that carries totals and OCR; a test now inflates the PDF content streams and asserts the glyph run comes after the last rectangle fill on every page. BETALD and MAKULERAD banners are left as they are. [2026-09-08] Negative journal-line amounts: fixed the sign at three levels (producers flip the SIDE via lib/bookkeeping/line-side.ts, the engine refuses negative amounts before any write, and a NOT VALID CHECK on journal_entry_lines) instead of only patching the supplier-invoice generator or hiding negative items in the form. Why: the invariant lived nowhere (no Zod rule, no engine check, no constraint), so MCP, templates and any future producer could repeat it; negative items themselves are valid input (rabatt, öresavrundning), so rejecting them at input would break real invoices. reverseEntry now swaps on the net so legacy negative lines storno cleanly before the data repair runs. +[2026-09-09] Onboarding search-as-you-type (#2448) reads SCB's företagsregister per debounced keystroke and runs TIC once, on the pick, instead of TIC per keystroke: the 3000/month Lens budget is why #2421 fired on Enter only, and SCB is free; the pick still needs TIC because SCB knows no F-skatt, VAT or fiscal year. No rate limit on either route, founder's call (2026-09-09): the debounce, the 3-char minimum and the abort of superseded requests are the only throttles. Sole traders are included in the onboarding search (SCB legal form 10) but stay excluded from the parties picker; the row names the form, never the personnummer. [2026-09-09] Bank picker: an UNCHECKED account holds its bokföringskonto only as a soft claim (yields to a checked account that wants it), instead of either a hard claim (status quo: 400 "Flera bankkonton kan inte bokföras på samma konto", the support dead end where 1930 could never move from the wrong bank account to the right one because the picker hides the ledger dropdown for unchecked rows and disconnect + reconnect re-claims the same rows by IBAN) or a full release on every save (fewer states, but it demotes every unchecked row to a manual ghost holding its ledger and loses the "re-check lands back on the same account" prefill). Contested rows are demoted to manual in ONE update before the mirror, and upsertFromPsd2 then promotes the manual holder in place, which keeps row ids, transactions.cash_account_id links and the is_primary flag on the 1930 row; the same release pass also makes two checked accounts swapping ledgers work (previously both upserts tripped the unique constraint and were swallowed per-account). Another connection's UNCHECKED row yields the same way (cash_accounts.enabled = false), matching how session sharing already counts only enabled rows as claims; a synced-elsewhere row stays a 400. No new client/UI: the picker needed no change once the server stopped counting unchecked rows. [2026-09-08] Zettle v1 = paid purchases→webshop_orders via Purchase API; Finance payouts out of scope; OAuth refresh-token store; unpaid IZETTLE_INVOICE-only skipped. diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx index 5979e6f7..0120b5c7 100644 --- a/app/(onboarding)/onboarding/page.tsx +++ b/app/(onboarding)/onboarding/page.tsx @@ -9,6 +9,7 @@ import { import type { EntityType } from '@/types' import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types' import { mapSetupEntityType as mapTicEntityType } from '@/lib/company-lookup/entity-type-map' +import { isScbConfigured } from '@/lib/parties/scb/config' export const dynamic = 'force-dynamic' @@ -123,6 +124,9 @@ export default async function OnboardingPage({ // A deep-linked orgnr is a deliberate create-this-company pick from the // BankID list: don't distract that flow with the invite hint. hasPendingInvite={hasPendingInvite && !initialOrgNumber} + // SCB credentials are server env: the client learns once whether the + // search-as-you-type picker exists in this environment. + companySearchEnabled={isScbConfigured()} /> ) } diff --git a/app/api/company/search/__tests__/route.test.ts b/app/api/company/search/__tests__/route.test.ts new file mode 100644 index 00000000..b63db76c --- /dev/null +++ b/app/api/company/search/__tests__/route.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), + createServiceClient: vi.fn(), +})) + +vi.mock('@/lib/parties/scb/config', () => ({ + isScbConfigured: vi.fn(), + scbConfigFromEnv: vi.fn(() => ({ baseUrl: 'https://scb.test', pfx: Buffer.from('x'), passphrase: 'p', timeoutMs: 1 })), +})) + +const searchByName = vi.fn() +vi.mock('@/lib/parties/scb/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createScbClient: vi.fn(() => ({ searchByName })) } +}) + +import { createClient } from '@/lib/supabase/server' +import { isScbConfigured } from '@/lib/parties/scb/config' +import { ScbApiError } from '@/lib/parties/scb/transport' +import { GET } from '../route' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const mockCreateClient = vi.mocked(createClient) +const mockIsScbConfigured = vi.mocked(isScbConfigured) + +function buildSupabase(user: { id: string } | null) { + return { + auth: { getUser: vi.fn().mockResolvedValue({ data: { user } }) }, + } +} + +const candidate = (over: Record = {}) => ({ + orgNumber: '5566778899', + name: 'Testbrand AB', + city: 'Malmö', + industry: null, + legalForm: 'Aktiebolag', + legalFormCode: '49', + status: 'Är verksam', + active: true, + ...over, +}) + +beforeEach(() => { + vi.clearAllMocks() + mockCreateClient.mockResolvedValue(buildSupabase({ id: 'user-1' }) as never) + mockIsScbConfigured.mockReturnValue(true) +}) + +describe('GET /api/company/search', () => { + it('returns 401 when unauthenticated', async () => { + mockCreateClient.mockResolvedValue(buildSupabase(null) as never) + const res = await GET(createMockRequest('/api/company/search?q=Testbrand')) + expect(res.status).toBe(401) + expect(searchByName).not.toHaveBeenCalled() + }) + + it('returns 400 for a query shorter than three characters', async () => { + const res = await GET(createMockRequest('/api/company/search?q=Te')) + expect(res.status).toBe(400) + expect(searchByName).not.toHaveBeenCalled() + }) + + it('returns 400 when q is missing', async () => { + const res = await GET(createMockRequest('/api/company/search')) + expect(res.status).toBe(400) + }) + + it('returns 400 for a number: that is the orgnr path, not a register scan', async () => { + const res = await GET(createMockRequest('/api/company/search?q=556677-8899')) + expect(res.status).toBe(400) + expect(searchByName).not.toHaveBeenCalled() + }) + + it('returns 503 SCB_NOT_CONFIGURED without credentials', async () => { + mockIsScbConfigured.mockReturnValue(false) + const res = await GET(createMockRequest('/api/company/search?q=Testbrand')) + expect(res.status).toBe(503) + const { body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(body.error.code).toBe('SCB_NOT_CONFIGURED') + expect(searchByName).not.toHaveBeenCalled() + }) + + it('asks SCB with sole traders included and maps the rows the picker needs', async () => { + searchByName.mockResolvedValue({ + query: 'Testbrand', + mode: 'starts_with', + total: 2, + truncated: false, + candidates: [candidate(), candidate({ orgNumber: '8001011234', name: 'ANDERSSON, ANNA', legalFormCode: '10', legalForm: 'Fysisk person', city: 'Lund' })], + }) + const res = await GET(createMockRequest('/api/company/search?q=Testbrand')) + expect(res.status).toBe(200) + expect(searchByName).toHaveBeenCalledWith('Testbrand', { includeSoleTraders: true }) + const { body } = await parseJsonResponse<{ data: { suggestions: unknown[]; truncated: boolean } }>(res) + expect(body.data.truncated).toBe(false) + expect(body.data.suggestions).toEqual([ + { orgNumber: '5566778899', name: 'Testbrand AB', city: 'Malmö', legalEntityType: 'AB', active: true }, + { orgNumber: '8001011234', name: 'ANDERSSON, ANNA', city: 'Lund', legalEntityType: 'EF', active: true }, + ]) + }) + + it('caps the list at the picker size and says so', async () => { + searchByName.mockResolvedValue({ + query: 'Test', + mode: 'starts_with', + total: 8, + truncated: false, + candidates: Array.from({ length: 8 }, (_, i) => candidate({ orgNumber: `556677889${i}`, name: `Testbrand ${i} AB` })), + }) + const res = await GET(createMockRequest('/api/company/search?q=Test')) + const { body } = await parseJsonResponse<{ data: { suggestions: unknown[]; truncated: boolean } }>(res) + expect(body.data.suggestions).toHaveLength(6) + expect(body.data.truncated).toBe(true) + }) + + it('passes through a flood as an empty, truncated list', async () => { + searchByName.mockResolvedValue({ query: 'Sve', mode: 'contains', total: 593, truncated: true, candidates: [] }) + const res = await GET(createMockRequest('/api/company/search?q=Sve')) + const { body } = await parseJsonResponse<{ data: { suggestions: unknown[]; truncated: boolean } }>(res) + expect(body.data).toEqual({ suggestions: [], truncated: true }) + }) + + it('returns 502 SCB_LOOKUP_FAILED when SCB does not answer', async () => { + searchByName.mockRejectedValue(new ScbApiError('boom', 500, '')) + const res = await GET(createMockRequest('/api/company/search?q=Testbrand')) + expect(res.status).toBe(502) + const { body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(body.error.code).toBe('SCB_LOOKUP_FAILED') + }) +}) diff --git a/app/api/company/search/route.ts b/app/api/company/search/route.ts new file mode 100644 index 00000000..3dadd475 --- /dev/null +++ b/app/api/company/search/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' +import { validateQuery } from '@/lib/api/validate' +import { CompanySearchQuerySchema } from '@/lib/api/schemas' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { createLogger } from '@/lib/logger' +import { createScbClient } from '@/lib/parties/scb/client' +import { isScbConfigured, scbConfigFromEnv } from '@/lib/parties/scb/config' +import { ScbApiError } from '@/lib/parties/scb/transport' +import { COMPANY_SUGGEST_MAX } from '@/lib/company-lookup/types' +import { toCompanySuggestion } from '@/lib/company-lookup/scb-suggestion' + +/** + * GET /api/company/search?q=: companies whose registered name starts with + * (or, failing that, contains) the query, for the search-as-you-type picker + * on the onboarding orgnr step. Reads SCB's företagsregister, which is + * free, so the client may call it per debounced keystroke; the paid TIC + * lookup runs once, on the pick, exactly as for a typed orgnr. + * + * Sole traders are included: a person searching for their own firm is the + * point of the step. Their org number is a personnummer, so the client + * names the form and never prints it. + * + * No company context: the caller is in the middle of creating one. + * requireAuth() (not a raw getUser()) keeps MFA AAL2 enforced on hosted. + */ +export async function GET(request: Request) { + const { error: authError } = await requireAuth() + if (authError) return authError + + const requestId = `req_${crypto.randomUUID()}` + const log = createLogger('company.search', { requestId }) + + const validated = validateQuery(request, CompanySearchQuerySchema, { log, operation: 'company.search' }) + if (!validated.success) return validated.response + const q = validated.data.q + + // Digits are an orgnr: that path is the TIC lookup, not a register scan. + if (/^[\d\s-]+$/.test(q)) { + return errorResponseFromCode('VALIDATION_ERROR', log, { requestId, reason: 'q must be a company name, not a number' }) + } + if (!isScbConfigured()) return errorResponseFromCode('SCB_NOT_CONFIGURED', log, { requestId }) + + try { + const result = await createScbClient(scbConfigFromEnv()).searchByName(q, { includeSoleTraders: true }) + const suggestions = result.candidates.slice(0, COMPANY_SUGGEST_MAX).map(toCompanySuggestion) + return NextResponse.json({ + data: { suggestions, truncated: result.truncated || result.candidates.length > COMPANY_SUGGEST_MAX }, + }) + } catch (err) { + log.warn('scb search failed', { + 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/companies/new-client/page.tsx b/app/companies/new-client/page.tsx index 720691ac..f59b28a3 100644 --- a/app/companies/new-client/page.tsx +++ b/app/companies/new-client/page.tsx @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import OnboardingBackdrop from '@/components/onboarding/OnboardingBackdrop' import OnboardingJourney from '@/components/onboarding/journey/OnboardingJourney' +import { isScbConfigured } from '@/lib/parties/scb/config' import { getByraMembership } from '@/lib/clients/fetch-client-overview' export const dynamic = 'force-dynamic' @@ -36,7 +37,7 @@ export default async function NewClientCompanyPage() { return (
- +
) } diff --git a/app/companies/new/page.tsx b/app/companies/new/page.tsx index 22e2bf84..38661263 100644 --- a/app/companies/new/page.tsx +++ b/app/companies/new/page.tsx @@ -3,6 +3,7 @@ import { redirect } from 'next/navigation' import OnboardingBackdrop from '@/components/onboarding/OnboardingBackdrop' import OnboardingJourney from '@/components/onboarding/journey/OnboardingJourney' import { SessionTimeoutController } from '@/components/auth/SessionTimeoutController' +import { isScbConfigured } from '@/lib/parties/scb/config' export const dynamic = 'force-dynamic' @@ -33,7 +34,7 @@ export default async function NewCompanyPage() {
- +
) } diff --git a/components/onboarding/journey/OnboardingJourney.tsx b/components/onboarding/journey/OnboardingJourney.tsx index e3c4b34c..54baaedb 100644 --- a/components/onboarding/journey/OnboardingJourney.tsx +++ b/components/onboarding/journey/OnboardingJourney.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type KeyboardEvent } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import { useLocale, useTranslations } from 'next-intl' @@ -8,9 +8,17 @@ import { createCompanyFromOnboarding } from '@/lib/company/actions' import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period' import { deriveFirstYearDefaults } from '@/lib/company/first-year-defaults' import { parseStartMonthDay } from '@/lib/company/first-year-defaults' -import { fetchCompanyLookup, fetchCompanySearch } from '@/lib/company-lookup/fetch-company-lookup' +import { + fetchCompanyLookup, + fetchCompanySearch, + fetchCompanySuggestions, +} from '@/lib/company-lookup/fetch-company-lookup' import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number' -import { COMPANY_SEARCH_MIN_CHARS, type CompanySearchHit } from '@/lib/company-lookup/types' +import { + COMPANY_SEARCH_MIN_CHARS, + type CompanySearchHit, + type CompanySuggestion, +} from '@/lib/company-lookup/types' import { mapEntityType } from '@/lib/company-lookup/entity-type-map' import { formatOrgNumber } from '@/lib/utils' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' @@ -61,12 +69,22 @@ import './journey.css' * wizard sends today. * * TIC budget: fetchCompanyLookup fires exactly once per confirmed orgnr - * (Enter, or the auto-submitted BankID deep link). No debounce-per-key, - * no roster prefetch. The advisory dup check is an internal endpoint. + * (Enter, the auto-submitted BankID deep link, or a picked suggestion). + * The search-as-you-type picker under the field is SCB (free), never TIC. + * The advisory dup check is an internal endpoint. */ const STATION_FRACS = [0.07, 0.285, 0.5, 0.715, 0.93] +/** Keystroke-to-search delay for the SCB picker: long enough to skip the + * middle of a word, short enough to feel live. */ +const SUGGEST_DEBOUNCE_MS = 300 + +/** Digits, spaces and dashes only: the orgnr path, never a name search. */ +function looksLikeOrgNumber(raw: string): boolean { + return /^[\d\s-]+$/.test(raw.trim()) +} + const LOG = '[onboarding-journey]' function logError(message: string, extra?: Record) { console.error(LOG, message, extra ?? '') @@ -101,6 +119,9 @@ interface OnboardingJourneyProps { * likely landed here by mistake (lost invite cookie), so the first * question carries a "join via the link in the email" hint. */ hasPendingInvite?: boolean + /** SCB credentials exist in this environment: the orgnr field suggests + * companies while a name is typed. Off: the field is orgnr-or-Enter. */ + companySearchEnabled?: boolean } export default function OnboardingJourney({ @@ -110,6 +131,7 @@ export default function OnboardingJourney({ initialEntityType, initialLegalName, hasPendingInvite = false, + companySearchEnabled = false, }: OnboardingJourneyProps) { const router = useRouter() const t = useTranslations('onboarding') @@ -133,6 +155,17 @@ export default function OnboardingJourney({ const [monogram, setMonogram] = useState(null) const [dupName, setDupName] = useState(null) const [dupElsewhere, setDupElsewhere] = useState(false) + // The SCB picker: rows for the current text, whether SCB cut the list, + // and the keyboard-highlighted row (-1: none, Enter runs the Enter path). + const [suggestions, setSuggestions] = useState([]) + const [suggestTruncated, setSuggestTruncated] = useState(false) + const [suggestActive, setSuggestActive] = useState(-1) + // Set once the environment answers 503: stops every further call. + const suggestDisabled = useRef(!companySearchEnabled) + // The text the user last confirmed (Enter, or a picked row): the picker + // does not reopen for it, so the #2421 chip row or the nomatch note + // stands alone until the text changes. + const lastConfirmed = useRef(null) const station = stationOfStep(state.step) const entity = state.settings.entity_type @@ -204,6 +237,7 @@ export default function OnboardingJourney({ // chip row; the pick re-checks for the number it resolves to. setDupName(null) setDupElsewhere(false) + lastConfirmed.current = trimmed dispatch({ type: 'SEARCH_SUBMITTED', query: trimmed }) fetchCompanySearch(trimmed, { ticEnabled }).then((outcome) => { dispatch({ type: 'SEARCH_RESULT', outcome }) @@ -226,6 +260,103 @@ export default function OnboardingJourney({ [checkDuplicate], ) + // Search-as-you-type: SCB per debounced keystroke while the text is a + // name of three or more characters. A newer keystroke aborts the request + // in flight, and a response for text the user has since left is dropped, + // so the list never lags behind the field. Costs no TIC. Quiet while the + // Enter path shows its chip row and for text already confirmed. + useEffect(() => { + const query = orgInput.trim() + if ( + suggestDisabled.current || + state.step !== 'orgnr' || + state.lookupPending || + state.searchHits.length > 0 || + query === lastConfirmed.current + ) { + setSuggestions([]) + setSuggestTruncated(false) + setSuggestActive(-1) + return + } + if (query.length < COMPANY_SEARCH_MIN_CHARS || looksLikeOrgNumber(query)) { + setSuggestions([]) + setSuggestTruncated(false) + setSuggestActive(-1) + return + } + const controller = new AbortController() + const timer = window.setTimeout(() => { + fetchCompanySuggestions(query, { signal: controller.signal }).then((outcome) => { + if (controller.signal.aborted) return + if (outcome.status === 'disabled') suggestDisabled.current = true + const rows = outcome.status === 'found' ? outcome.suggestions : [] + setSuggestions(rows) + setSuggestTruncated(outcome.status === 'found' || outcome.status === 'empty' ? outcome.truncated : false) + setSuggestActive(-1) + }) + }, SUGGEST_DEBOUNCE_MS) + return () => { + window.clearTimeout(timer) + controller.abort() + } + }, [orgInput, state.step, state.lookupPending, state.searchHits.length]) + + // A picked suggestion is an orgnr the user confirmed: the same single TIC + // lookup as Enter on a typed number, plus the advisory dup check. The + // field shows the company's name, never its number (a sole trader's is + // their personnummer); on Back the name stands until the user edits it. + const pickSuggestion = useCallback( + (suggestion: CompanySuggestion) => { + setSuggestions([]) + setSuggestTruncated(false) + setSuggestActive(-1) + lastConfirmed.current = suggestion.name.trim() + setOrgInput(suggestion.name) + setDupName(null) + setDupElsewhere(false) + dispatch({ type: 'SUGGESTION_PICKED', suggestion }) + fetchCompanyLookup(suggestion.orgNumber, { ticEnabled }).then((outcome) => { + dispatch({ type: 'LOOKUP_RESULT', outcome }) + }) + checkDuplicate(suggestion.orgNumber) + }, + [ticEnabled, checkDuplicate], + ) + + const onOrgKeyDown = useCallback( + (e: KeyboardEvent) => { + if (state.lookupPending) return + const open = suggestions.length > 0 + if (open && e.key === 'ArrowDown') { + e.preventDefault() + setSuggestActive((i) => (i + 1) % suggestions.length) + return + } + if (open && e.key === 'ArrowUp') { + e.preventDefault() + setSuggestActive((i) => (i <= 0 ? suggestions.length - 1 : i - 1)) + return + } + if (open && e.key === 'Escape') { + e.preventDefault() + setSuggestions([]) + setSuggestActive(-1) + return + } + if (e.key === 'Enter') { + const active = suggestActive >= 0 ? suggestions[suggestActive] : undefined + if (active) { + e.preventDefault() + pickSuggestion(active) + return + } + submitOrg(orgInput) + } + }, + [state.lookupPending, suggestions, suggestActive, pickSuggestion, submitOrg, orgInput], + ) + // BankID deep link: auto-submit the orgnr once on mount (the single // lookup replaces the wizard's preverified suppression, per the plan // addendum). Guarded against strict-mode double-invoke. @@ -409,7 +540,7 @@ export default function OnboardingJourney({ case 'orgnr': return ( setOrgInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !state.lookupPending) submitOrg(orgInput) + role="combobox" + aria-autocomplete="list" + aria-expanded={suggestions.length > 0} + aria-controls="jny-suggest-list" + aria-activedescendant={suggestActive >= 0 ? `jny-suggest-${suggestActive}` : undefined} + onChange={(e) => { + // A highlight belongs to the rows for the previous text. + setSuggestActive(-1) + setOrgInput(e.target.value) }} + onKeyDown={onOrgKeyDown} /> - {state.searchHits.length > 1 ? ( + {/* In flow, not floated: the step scrolls (overflow-y: auto), so an + absolutely positioned list would be clipped to the field. */} + {suggestions.length > 0 ? ( + <> +
    + {suggestions.map((s, i) => { + // A sole trader's org number is their personnummer: + // the row names the form instead, never the number. + const isSoleTrader = mapEntityType(s.legalEntityType) === 'enskild_firma' + const ident = isSoleTrader ? t('journey_form_ef') : formatOrgNumber(s.orgNumber) + const sub = [ident, s.city, s.active ? null : t('journey_suggest_inactive')] + .filter(Boolean) + .join(' · ') + return ( +
  • setSuggestActive(i)} + // mousedown, not click: the input's blur must not close the list first. + onMouseDown={(e) => { + e.preventDefault() + pickSuggestion(s) + }} + > + {s.name} + {sub} +
  • + ) + })} +
+ {suggestTruncated ?

{t('journey_suggest_more')}

: null} + + ) : suggestTruncated ? ( + // SCB counted a flood for a short prefix and sent no rows. +

{t('journey_suggest_more')}

+ ) : state.searchHits.length > 1 ? ( <>

{t('journey_search_pick')}

{ + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + it('returns disabled without fetching below the minimum length', async () => { + expect(await fetchCompanySuggestions('Te')).toEqual({ status: 'disabled' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('calls the core search route, never TIC, and returns the rows', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, { data: { suggestions: [ROW], truncated: false } })) + const outcome = await fetchCompanySuggestions(' Testbrand ') + expect(fetchMock.mock.calls[0]![0]).toBe('/api/company/search?q=Testbrand') + expect(outcome).toEqual({ status: 'found', suggestions: [ROW], truncated: false }) + }) + + it('reports an empty list with the truncation flag so the field can ask for more', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, { data: { suggestions: [], truncated: true } })) + expect(await fetchCompanySuggestions('Sve')).toEqual({ status: 'empty', truncated: true }) + }) + + it('drops malformed rows and treats a malformed body as an error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: { suggestions: [ROW, { name: 1 }, null], truncated: false } })) + expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'found', suggestions: [ROW], truncated: false }) + fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: { nope: true } })) + expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'error' }) + }) + + it('maps the not-configured 503 to disabled and every other failure to error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(503, { error: { code: 'SCB_NOT_CONFIGURED' } })) + expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'disabled' }) + fetchMock.mockResolvedValueOnce(new Response('Service Unavailable', { status: 503 })) + expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'error' }) + fetchMock.mockResolvedValueOnce(jsonResponse(502, { error: { code: 'SCB_LOOKUP_FAILED' } })) + expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'error' }) + fetchMock.mockResolvedValueOnce(jsonResponse(401, { error: { code: 'UNAUTHORIZED' } })) + expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'error' }) + fetchMock.mockRejectedValueOnce(new TypeError('network')) + expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'error' }) + }) + + it('returns aborted when the caller cancels', async () => { + const abortErr = Object.assign(new Error('aborted'), { name: 'AbortError' }) + fetchMock.mockRejectedValueOnce(abortErr) + expect(await fetchCompanySuggestions('Testbrand')).toEqual({ status: 'aborted' }) + + const controller = new AbortController() + fetchMock.mockImplementationOnce(async () => { + controller.abort() + return jsonResponse(200, { data: { suggestions: [ROW], truncated: false } }) + }) + expect(await fetchCompanySuggestions('Testbrand', { signal: controller.signal })).toEqual({ status: 'aborted' }) + }) +}) diff --git a/lib/company-lookup/__tests__/scb-suggestion.test.ts b/lib/company-lookup/__tests__/scb-suggestion.test.ts new file mode 100644 index 00000000..c117aabf --- /dev/null +++ b/lib/company-lookup/__tests__/scb-suggestion.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { toCompanySuggestion } from '../scb-suggestion' +import type { ScbCandidate } from '@/lib/parties/scb/client' + +const candidate = (over: Partial = {}): ScbCandidate => ({ + orgNumber: '5566778899', + name: 'Testbrand AB', + city: 'Malmö', + industry: null, + legalForm: 'Aktiebolag', + legalFormCode: '49', + status: 'Är verksam', + active: true, + ...over, +}) + +describe('toCompanySuggestion', () => { + it('maps the forms the journey sets up and leaves the rest to the user', () => { + expect(toCompanySuggestion(candidate()).legalEntityType).toBe('AB') + expect(toCompanySuggestion(candidate({ legalFormCode: '10' })).legalEntityType).toBe('EF') + expect(toCompanySuggestion(candidate({ legalFormCode: '61' })).legalEntityType).toBe('Ideell förening') + // Insurance AB, bank AB, ekonomisk förening, stiftelse: not a plain AB. + for (const code of ['42', '41', '51', '72', null]) { + expect(toCompanySuggestion(candidate({ legalFormCode: code })).legalEntityType).toBeNull() + } + }) + + it('carries only what the picker shows plus the number it resolves to', () => { + expect(toCompanySuggestion(candidate({ active: false }))).toEqual({ + orgNumber: '5566778899', + name: 'Testbrand AB', + city: 'Malmö', + legalEntityType: 'AB', + active: false, + }) + }) +}) diff --git a/lib/company-lookup/fetch-company-lookup.ts b/lib/company-lookup/fetch-company-lookup.ts index 228655c0..baa52e12 100644 --- a/lib/company-lookup/fetch-company-lookup.ts +++ b/lib/company-lookup/fetch-company-lookup.ts @@ -1,5 +1,5 @@ import { COMPANY_SEARCH_MIN_CHARS } from './types' -import type { CompanyLookupResult, CompanySearchHit } from './types' +import type { CompanyLookupResult, CompanySearchHit, CompanySuggestion } from './types' import { normalizeOrgNumber } from './normalize-org-number' /** @@ -121,6 +121,65 @@ export async function fetchCompanySearch( return mapFailure(res) } +export type CompanySuggestOutcome = + | { status: 'found'; suggestions: CompanySuggestion[]; truncated: boolean } + | { status: 'empty'; truncated: boolean } + | { status: 'disabled' } + | { status: 'error' } + | { status: 'aborted' } + +/** + * Search-as-you-type for the journey's orgnr field: SCB's företagsregister + * via the core route, never TIC. Free, so the caller may fire it per + * debounced keystroke; the AbortSignal drops the superseded request. A + * 503 (SCB not configured in this environment) is `disabled` so the field + * quietly stays an orgnr-or-Enter field; every other failure is `error` + * and the picker just does not appear. Never throws. + */ +export async function fetchCompanySuggestions( + query: string, + opts: { signal?: AbortSignal } = {}, +): Promise { + const trimmed = query.trim() + if (trimmed.length < COMPANY_SEARCH_MIN_CHARS) return { status: 'disabled' } + + let res: Response + try { + res = await fetch(`/api/company/search?q=${encodeURIComponent(trimmed)}`, { signal: opts.signal }) + } catch (err) { + if ((err as Error).name === 'AbortError') return { status: 'aborted' } + return { status: 'error' } + } + if (opts.signal?.aborted) return { status: 'aborted' } + + if (res.ok) { + try { + const { data } = (await res.json()) as { + data: { suggestions: CompanySuggestion[]; truncated: boolean } + } + if (!data || !Array.isArray(data.suggestions)) return { status: 'error' } + const suggestions = data.suggestions.filter( + (h) => h && typeof h.orgNumber === 'string' && typeof h.name === 'string', + ) + const truncated = data.truncated === true + return suggestions.length > 0 ? { status: 'found', suggestions, truncated } : { status: 'empty', truncated } + } catch { + return { status: 'error' } + } + } + // Only the route's own "no SCB credentials here" switches the picker off + // for the session; an infrastructure 503 is transient like any other. + if (res.status === 503) { + try { + const body = (await res.json()) as { error?: { code?: unknown } } + if (body?.error?.code === 'SCB_NOT_CONFIGURED') return { status: 'disabled' } + } catch { + // Non-JSON 503: transient. + } + } + return { status: 'error' } +} + /** Shared non-ok mapping: dispatcher misses degrade silently, only the TIC * handler's own 404 is a user-facing "not found". */ async function mapFailure( diff --git a/lib/company-lookup/scb-suggestion.ts b/lib/company-lookup/scb-suggestion.ts new file mode 100644 index 00000000..cfed0642 --- /dev/null +++ b/lib/company-lookup/scb-suggestion.ts @@ -0,0 +1,26 @@ +import { SCB_LEGAL_FORM_SOLE_TRADER, type ScbCandidate } from '@/lib/parties/scb/client' +import type { CompanySuggestion } from './types' + +/** + * SCB legal form codes the journey may prefill, expressed in the TIC + * vocabulary `mapSetupEntityType` already understands: 49 (aktiebolag), + * 10 (enskild näringsidkare) and 61 (ideell förening, which the flag may + * still refuse at setup). Bank and insurance AB (41, 42), ekonomisk + * förening (51), stiftelser and the rest stay null: the user picks the + * form, as after a TIC lookup with an unmapped type. + */ +const LEGAL_ENTITY_TYPE_BY_SCB_CODE: Record = { + '49': 'AB', + [SCB_LEGAL_FORM_SOLE_TRADER]: 'EF', + '61': 'Ideell förening', +} + +export function toCompanySuggestion(c: ScbCandidate): CompanySuggestion { + return { + orgNumber: c.orgNumber, + name: c.name, + city: c.city, + legalEntityType: (c.legalFormCode && LEGAL_ENTITY_TYPE_BY_SCB_CODE[c.legalFormCode]) || null, + active: c.active, + } +} diff --git a/lib/company-lookup/types.ts b/lib/company-lookup/types.ts index 4b652a6d..e10639eb 100644 --- a/lib/company-lookup/types.ts +++ b/lib/company-lookup/types.ts @@ -73,3 +73,23 @@ export interface CompanySearchHit { * so the two never disagree on what is worth a provider call. */ export const COMPANY_SEARCH_MIN_CHARS = 3 + +/** + * One row of the search-as-you-type picker on the onboarding orgnr step, + * from SCB's företagsregister (free): enough to recognise the company and + * to run the single TIC lookup once it is picked. `legalEntityType` uses + * the same vocabulary as CompanyLookupResult so the reducer maps it with + * mapSetupEntityType; null when SCB's legal form is not one we set up. + * A sole trader's `orgNumber` is the owner's personnummer: the picker + * names the form instead of printing it. + */ +export interface CompanySuggestion { + orgNumber: string + name: string + city: string | null + legalEntityType: string | null + active: boolean +} + +/** Rows the picker shows; SCB may return more, the client keeps a picker a picker. */ +export const COMPANY_SUGGEST_MAX = 6 diff --git a/lib/onboarding-journey/__tests__/reducer-search.test.ts b/lib/onboarding-journey/__tests__/reducer-search.test.ts index f2b4b566..618b612f 100644 --- a/lib/onboarding-journey/__tests__/reducer-search.test.ts +++ b/lib/onboarding-journey/__tests__/reducer-search.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { initJourney, journeyReducer, type JourneyAction, type JourneyState } from '../reducer' -import type { CompanyLookupResult, CompanySearchHit } from '@/lib/company-lookup/types' +import type { CompanyLookupResult, CompanySearchHit, CompanySuggestion } from '@/lib/company-lookup/types' function lookup(overrides: Partial = {}): CompanyLookupResult { return { @@ -215,3 +215,133 @@ describe('journeyReducer: name search', () => { expect(s.searchHits).toEqual([]) }) }) + +describe('journeyReducer: search-as-you-type pick (SCB row, TIC on pick)', () => { + const suggestion = (overrides: Partial = {}): CompanySuggestion => ({ + orgNumber: '5566778899', + name: 'Testbrand AB', + city: 'Malmö', + legalEntityType: 'AB', + active: true, + ...overrides, + }) + + it('SUGGESTION_PICKED stores the orgnr and prefill, then waits for the single TIC lookup', () => { + const s = journeyReducer(initJourney(), { type: 'SUGGESTION_PICKED', suggestion: suggestion() }) + expect(s.step).toBe('orgnr') + expect(s.lookupPending).toBe(true) + expect(s.lookupRan).toBe(false) + expect(s.ticLookup).toBeNull() + expect(s.settings.org_number).toBe('5566778899') + expect(s.settings.company_name).toBe('Testbrand AB') + expect(s.settings.entity_type).toBe('aktiebolag') + expect(s.searchHits).toEqual([]) + }) + + it('a picked row followed by a TIC answer lands exactly where a typed orgnr does', () => { + const viaPick = run( + initJourney(), + { type: 'SUGGESTION_PICKED', suggestion: suggestion() }, + { type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } }, + ) + const viaOrg = run( + initJourney(), + { type: 'ORG_SUBMITTED', orgNumber: '5566778899' }, + { type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } }, + ) + expect(viaPick.step).toBe('fy') + expect(viaPick.settings).toEqual(viaOrg.settings) + expect(viaPick.lookupRan).toBe(true) + }) + + it('TIC facts override the SCB prefill', () => { + const s = run( + initJourney(), + { type: 'SUGGESTION_PICKED', suggestion: suggestion({ name: 'TESTBRAND AKTIEBOLAG' }) }, + { type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup({ companyName: 'Testbrand AB' }) } }, + ) + expect(s.settings.company_name).toBe('Testbrand AB') + }) + + it('with TIC off, the SCB prefill carries the AB past the form and name questions', () => { + const s = run( + initJourney(), + { type: 'SUGGESTION_PICKED', suggestion: suggestion() }, + { type: 'LOOKUP_RESULT', outcome: { status: 'disabled' } }, + ) + // Name and form known, address not: the degraded path asks for it. + expect(s.step).toBe('address') + expect(s.lookupRan).toBe(false) + expect(s.settings.company_name).toBe('Testbrand AB') + expect(s.settings.entity_type).toBe('aktiebolag') + }) + + it('an unmapped legal form falls through to the form picker on the degraded path', () => { + const s = run( + initJourney(), + { type: 'SUGGESTION_PICKED', suggestion: suggestion({ legalEntityType: null }) }, + { type: 'LOOKUP_RESULT', outcome: { status: 'error' } }, + ) + expect(s.step).toBe('form') + expect(s.lookupNote).toBe('error') + expect(s.settings.org_number).toBe('5566778899') + }) + + it('a sole trader row still confirms the verksamhetsnamn', () => { + const s = run( + initJourney(), + { type: 'SUGGESTION_PICKED', suggestion: suggestion({ orgNumber: '8001011234', name: 'ANDERSSON, ANNA', legalEntityType: 'EF' }) }, + { type: 'LOOKUP_RESULT', outcome: { status: 'disabled' } }, + ) + expect(s.settings.entity_type).toBe('enskild_firma') + expect(s.step).toBe('name') + }) + + it('a pick replaces a previous orgnr and its facts', () => { + const s = run( + initJourney(), + { type: 'ORG_SUBMITTED', orgNumber: '1111111111' }, + { type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } }, + { type: 'NOTFOUND_EDIT' }, + { type: 'SUGGESTION_PICKED', suggestion: suggestion() }, + ) + expect(s.settings.org_number).toBe('5566778899') + expect(s.ticLookup).toBeNull() + expect(s.lookupNote).toBe('none') + }) + + it('editing the number after a missed pick drops the pick\'s name and form', () => { + const s = run( + initJourney(), + { type: 'SUGGESTION_PICKED', suggestion: suggestion({ name: 'Alpha AB' }) }, + { type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } }, + { type: 'NOTFOUND_EDIT' }, + { type: 'ORG_SUBMITTED', orgNumber: '2222222222' }, + { type: 'LOOKUP_RESULT', outcome: { status: 'error' } }, + ) + expect(s.settings.company_name).toBeUndefined() + expect(s.settings.entity_type).toBeUndefined() + expect(s.step).toBe('form') + }) + + it('editing the number keeps a BankID prefill, which was never about the number', () => { + const s = run( + initJourney({ initialOrgNumber: '1111111111', initialEntityType: 'aktiebolag', initialLegalName: 'Roles AB' }), + { type: 'ORG_SUBMITTED', orgNumber: '1111111111' }, + { type: 'LOOKUP_RESULT', outcome: { status: 'not_found' } }, + { type: 'NOTFOUND_EDIT' }, + ) + expect(s.settings.company_name).toBe('Roles AB') + expect(s.settings.entity_type).toBe('aktiebolag') + }) + + it('is ignored off the orgnr step and while submitting', () => { + const later = run( + initJourney(), + { type: 'ORG_SUBMITTED', orgNumber: '5566778899' }, + { type: 'LOOKUP_RESULT', outcome: { status: 'found', result: lookup() } }, + ) + expect(later.step).toBe('fy') + expect(journeyReducer(later, { type: 'SUGGESTION_PICKED', suggestion: suggestion({ orgNumber: '2222222222' }) })).toBe(later) + }) +}) diff --git a/lib/onboarding-journey/reducer.ts b/lib/onboarding-journey/reducer.ts index c940cc27..26d71c1b 100644 --- a/lib/onboarding-journey/reducer.ts +++ b/lib/onboarding-journey/reducer.ts @@ -1,5 +1,5 @@ import type { CompanySettings, EntityType, MomsPeriod } from '@/types' -import type { CompanyLookupResult, CompanySearchHit } from '@/lib/company-lookup/types' +import type { CompanyLookupResult, CompanySearchHit, CompanySuggestion } from '@/lib/company-lookup/types' import type { CompanyLookupOutcome, CompanySearchOutcome, @@ -121,6 +121,7 @@ export type JourneyAction = | { type: 'SEARCH_SUBMITTED'; query: string } | { type: 'SEARCH_RESULT'; outcome: CompanySearchOutcome } | { type: 'SEARCH_HIT_PICKED'; hit: CompanySearchHit } + | { type: 'SUGGESTION_PICKED'; suggestion: CompanySuggestion } | { type: 'NOTFOUND_CONTINUE' } | { type: 'NOTFOUND_EDIT' } | { type: 'CEASED_CONTINUE' } @@ -371,6 +372,32 @@ export function journeyReducer(state: JourneyState, action: JourneyAction): Jour return applyLookupFound(withOrgNumber(state, action.hit.orgNumber), action.hit.result) } + case 'SUGGESTION_PICKED': { + // A search-as-you-type row (SCB) resolves to an orgnr the same way a + // typed one does: the component fires the single TIC lookup next and + // LOOKUP_RESULT decides the step. What SCB already knows (name, form) + // is prefill for the degraded paths (TIC off, error, not found), and + // TIC's answer overrides it when it comes. lookupRan stays false: SCB + // says nothing about F-skatt, VAT or the fiscal year. + if (state.submitting || state.step !== 'orgnr') return state + const { suggestion } = action + const mapped = mapSetupEntityType(suggestion.legalEntityType) + return stay(state, { + settings: { + ...state.settings, + org_number: suggestion.orgNumber, + company_name: suggestion.name, + entity_type: mapped ?? state.settings.entity_type, + }, + ticLookup: null, + lookupRan: false, + lookupNote: 'none', + lookupPending: true, + searchHits: [], + serverError: null, + }) + } + case 'NOTFOUND_CONTINUE': { if (state.settings.entity_type) return go(state, nextCompanyStep(state)) return go(state, 'form') @@ -379,8 +406,15 @@ export function journeyReducer(state: JourneyState, action: JourneyAction): Jour case 'NOTFOUND_EDIT': case 'CEASED_EDIT': { // Back to the orgnr question; the fresh submit re-runs the single lookup. + // The abandoned number's name and form go with it (a picked SCB row or + // a ceased lookup put them there); BankID's CompanyRoles prefill stays, + // it was never about this number. return go(state, 'orgnr', { - settings: { ...state.settings, org_number: undefined }, + settings: { + ...state.settings, + org_number: undefined, + ...(state.viaPrefill ? {} : { company_name: undefined, entity_type: undefined }), + }, ticLookup: null, lookupRan: false, lookupNote: 'none', diff --git a/lib/parties/scb/__tests__/scb.test.ts b/lib/parties/scb/__tests__/scb.test.ts index b8715420..3558b2d1 100644 --- a/lib/parties/scb/__tests__/scb.test.ts +++ b/lib/parties/scb/__tests__/scb.test.ts @@ -181,6 +181,22 @@ describe('name search', () => { expect(SCB_SEARCH_CAP).toBe(25) }) + it('offers sole traders only when asked, and never estates', async () => { + const json = async (_c: unknown, _m: string, path: string) => { + if (path.endsWith('RaknaForetag')) return 3 + return [row('5564082161', 'Adobe Systems Nordic Aktiebolag'), row('8001011234', 'ADOBE, ANNA', '1', '10'), row('8001011235', 'ADOBE, ANNA DÖDSBO', '1', '91')] + } + const client = createScbClient(cfg, { json: json as never }) + const parties = await client.searchByName('Adobe') + expect(parties.candidates.map((c) => c.orgNumber)).toEqual(['5564082161']) + const onboarding = await client.searchByName('Adobe', { includeSoleTraders: true }) + expect(onboarding.candidates.map((c) => [c.orgNumber, c.legalFormCode])).toEqual([ + ['5564082161', '49'], + ['8001011234', '10'], + ]) + expect(onboarding.total).toBe(2) + }) + it('does not call SCB for a query shorter than two characters', async () => { const json = async () => { throw new Error('should not be called') diff --git a/lib/parties/scb/client.ts b/lib/parties/scb/client.ts index c19c4ed4..ea51988a 100644 --- a/lib/parties/scb/client.ts +++ b/lib/parties/scb/client.ts @@ -28,6 +28,8 @@ export interface ScbCandidate { city: string | null industry: string | null legalForm: string | null + /** SCB's legal form code ("49" övriga aktiebolag, "10" enskild näringsidkare). */ + legalFormCode: string | null /** SCB's own status text; active is Företagsstatus code 1. */ status: string | null active: boolean @@ -47,13 +49,27 @@ export interface ScbClient { variables(): Promise categories(): Promise lookupByOrgNumber(orgNumber: string): Promise - searchByName(query: string): Promise + searchByName(query: string, opts?: ScbSearchOptions): Promise +} + +export interface ScbSearchOptions { + /** + * Offer enskilda näringsidkare (legal form 10) alongside legal persons. + * Off by default: the parties picker matches counterparts on supplier + * invoices, where a natural person is noise. Onboarding turns it on + * because a sole trader searching for their own firm is the point; the + * org number SCB returns there is the owner's personnummer, so the caller + * decides what to print. Estates (91) are never offered. + */ + includeSoleTraders?: boolean } /** 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']) +/** SCB legal form code for a natural person running a business (enskild näringsidkare). */ +export const SCB_LEGAL_FORM_SOLE_TRADER = '10' /** * What we send SCB for a name: the AP prefix, supplier numbers and a @@ -78,10 +94,11 @@ export function nameSearchBody(query: string, mode: 'starts_with' | 'contains') } } -function candidateFrom(row: ScbCompanyRow): ScbCandidate | null { +function candidateFrom(row: ScbCompanyRow, includeSoleTraders: boolean): 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 + if (org.length !== 10) return null + if (NON_COMPANY_LEGAL_FORMS.has(legalFormCode) && !(includeSoleTraders && legalFormCode === SCB_LEGAL_FORM_SOLE_TRADER)) return null const str = (k: string) => { const v = row[k] const t = v === null || v === undefined ? '' : String(v).trim() @@ -93,6 +110,7 @@ function candidateFrom(row: ScbCompanyRow): ScbCandidate | null { city: str('PostOrt'), industry: str('Bransch_1'), legalForm: str('Juridisk form'), + legalFormCode: legalFormCode || null, status: str('Företagsstatus'), active: String(row['Företagsstatus, kod'] ?? '').trim() === '1', } @@ -122,7 +140,8 @@ export function createScbClient(config: ScbConfig, deps: { json?: typeof scbJson 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) { + async searchByName(raw, opts = {}) { + const includeSoleTraders = opts.includeSoleTraders === true 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. @@ -132,7 +151,9 @@ export function createScbClient(config: ScbConfig, deps: { json?: typeof scbJson 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) + const all = (Array.isArray(rows) ? rows : []) + .map((r) => candidateFrom(r, includeSoleTraders)) + .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 diff --git a/messages/en.json b/messages/en.json index a31d31af..3b28e098 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1485,6 +1485,11 @@ "journey_orb_check": "Done", "journey_press": "Press", "journey_orgnr_title": "What is your organisation number?", + "journey_company_title": "Which company is it?", + "journey_company_placeholder": "Company name or organisation number", + "journey_suggest_label": "Matching companies", + "journey_suggest_more": "Many matches. Type more of the name.", + "journey_suggest_inactive": "Deregistered", "journey_err_org_invalid": "The organisation number was not accepted. Check it and try again.", "journey_lookup_error": "Company details could not be fetched right now. Continue manually.", "journey_notfound_title": "I can't find a company with that number.", diff --git a/messages/sv.json b/messages/sv.json index df8036b6..abf92cd4 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1485,6 +1485,11 @@ "journey_orb_check": "Klart", "journey_press": "Tryck", "journey_orgnr_title": "Vad är ert organisationsnummer?", + "journey_company_title": "Vilket företag är det?", + "journey_company_placeholder": "Företagsnamn eller organisationsnummer", + "journey_suggest_label": "Företag som matchar", + "journey_suggest_more": "Många träffar. Skriv mer av namnet.", + "journey_suggest_inactive": "Avregistrerat", "journey_err_org_invalid": "Organisationsnumret godkändes inte. Kontrollera och försök igen.", "journey_lookup_error": "Uppgifterna kunde inte hämtas just nu. Fortsätt manuellt.", "journey_notfound_title": "Jag hittar inget företag på det numret.",