Commit Graph
3 Commits
Author SHA1 Message Date
MattssonandClaude Fable 5.1 fc2d78a7c4 feat(onboarding): the orgnr step suggests companies as you type (SCB search, TIC on the pick) (#2452)
* feat(onboarding): the orgnr step suggests companies as you type, SCB search, TIC on the pick

Most people do not know their organisationsnummer. They left the
onboarding for allabolag, searched their company name there, copied the
number and pasted it back. #2421 let the field take a name, but only on
Enter and behind a screen that still said "organisationsnummer", so the
detour stayed. Now the field suggests companies while a name is typed
(name, orgnr or "Enskild firma", city; arrow keys or click to pick), the
pick fills the company like a typed orgnr, and the screen says "Vilket
företag är det?" with "Företagsnamn eller organisationsnummer" as the
placeholder.

Why the problem occurred: the one identifier the step asked for is the one
the user is least likely to remember, and the free-text path added in
#2421 was invisible (copy unchanged) and had to be guessed (Enter only),
because the only search index behind it was TIC, whose Lens budget cannot
take a call per keystroke.

What was removed or simplified: nothing is stored and no new state model:
a picked suggestion is an ORG_SUBMITTED with prefill, so the existing
LOOKUP_RESULT transitions (found, not found, disabled, error) decide the
step exactly as for a typed number. SCB's name search already existed for
the parties picker; it gained one option (sole traders) instead of a
second client. No rate limiting anywhere, per the founder.

Why this shape: SCB's företagsregister is free and already configured for
the parties picker, so search-as-you-type costs nothing while typing; TIC
runs once, on the pick, as it always did on Enter. TIC per keystroke was
rejected (3000/month). SCB alone was rejected for the pick because it
knows no F-skatt, VAT registration or fiscal year. The Enter path and the
chip row from #2421 stay as the fallback when no row is picked. Sole
traders are offered (they are half the users) but their row names the
form and never prints the personnummer, and the field shows the company
name after a pick for the same reason.

Changes:
- app/api/company/search: GET ?q= over the SCB client with sole traders
  included, top 6 rows plus a truncated flag; requireAuth() (no company
  yet), 400 for short or numeric q, 503 without SCB credentials, 502 when
  SCB does not answer.
- lib/parties/scb/client.ts: searchByName(query, { includeSoleTraders }),
  legalFormCode on every candidate; the parties picker is unchanged.
- lib/company-lookup: CompanySuggestion, COMPANY_SUGGEST_MAX,
  fetchCompanySuggestions (503 is disabled, everything else error, never
  throws), toCompanySuggestion (SCB legal form 49/10/61 into the TIC
  vocabulary mapSetupEntityType reads).
- lib/onboarding-journey/reducer.ts: SUGGESTION_PICKED (orgnr, name and
  form as prefill, lookupPending; lookupRan stays false until TIC answers).
- components/onboarding/journey: 300 ms debounced SCB search with abort of
  the superseded request, listbox under the field (combobox ARIA, arrow
  keys, Escape, Enter picks the highlighted row, otherwise the Enter path),
  copy switches with companySearchEnabled or ticEnabled; both journey
  pages pass isScbConfigured().
- messages sv+en: five strings.

Tests: route (401, 400 short, 400 missing, 400 numeric, 503, happy with a
sole trader, cap at 6, flood, 502); fetchCompanySuggestions (every
outcome); toCompanySuggestion; reducer (pick equals typed orgnr after TIC,
TIC overrides prefill, TIC off keeps the AB past form and name, unmapped
form falls to the picker, sole trader confirms the name, replaces a
previous orgnr, ignored off-step); SCB client sole-trader option.

Fixes #2448

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNDuYBHVu172tesKfJmcmi

* fix(onboarding): the suggestion list stays visible and stands alone (skeptic on e56eb242c)

Three independent refuters on the frozen commit; every refutation that
stood is fixed here.

- The listbox was position: absolute inside the field, but the step
  scrolls (.jny-qstep is overflow-y: auto), so the list was clipped to
  the first row and mouse picks were unreachable (measured in headless
  Chrome). It now renders in flow under the field, where the chip row
  from #2421 already lives.
- After Enter on a name (the #2421 path), SEARCH_RESULT flipped
  lookupPending back and the debounced effect refetched SCB, laying the
  listbox over the chip row or next to the nomatch note. The effect is
  now quiet while searchHits is non-empty and for text the user already
  confirmed (Enter or a pick), until the text changes.
- The "many matches, type more" hint only rendered inside the list, so
  the flood case (SCB counts over 100 rows and sends none) showed
  nothing. The hint now renders on its own for that case.
- app/companies/new-client (byrå adds a client) renders the same journey
  and now passes companySearchEnabled like the other two pages.
- A stale mouse highlight could commit a row from the previous text on
  Enter: typing resets the highlight.
- Any 503 switched the picker off for the session; only the route's own
  SCB_NOT_CONFIGURED does now.
- NOTFOUND_EDIT / CEASED_EDIT dropped only the number and kept the
  abandoned pick's name and form, which a later TIC error path would
  have written into the company. Both now drop name and form too, unless
  they came from BankID's CompanyRoles prefill, which is not about the
  number.

Not changed, recorded: a sole trader picked from SCB whom TIC does not
know lands on the "no company on that number" step with the name in the
field; the flow continues with the SCB name prefilled. The search JSON
carries the personnummer of sole-trader rows to the authenticated
browser (the row prints "Enskild firma"), same class as #2421's Enter
search; flagged to the founder.

Refs #2448

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNDuYBHVu172tesKfJmcmi

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 11:43:14 +02:00
82859d01db feat(parties): name the company inside a voucher text, and stop asking SCB about foreign ones (#2265)
* feat(parties): name the company inside a voucher text, and stop asking SCB about foreign ones

The registry picker searched SCB on the whole display name, which for an
assistant-written voucher is a sentence, so "1511768101 · Visma Spcs AB,
faktura ..." never matched and foreign suppliers produced an empty list
with no explanation.

- lib/parties/name-extract.ts: name candidates read out of the text,
  anchored on legal-form words (AB, AB (publ), Inc., Ltd, B.V., GmbH, Oy,
  ...) and on country words, plus EU VAT numbers. Every candidate is a
  substring of the text; foreign forms and countries mark the candidate
  as one SCB cannot hold.
- Suggestions: the display name prefers the legal person named in the
  text ("TIC identity" becomes "The Intelligence Company AB (publ)"),
  the voucher texts are stored as a ledger fact for the picker, the
  country is stored when the text says, and a single foreign VAT number
  in the text becomes the party's VAT number.
- GET .../enrich/candidates plans the search: Swedish legal person first,
  cleaned head last, at most three queries, stopping at the first hit;
  no SCB call when the best reading is foreign, the response says which
  company it read and where.
- Picker: "X ser ut att vara ett utländskt bolag (Irland). SCB:s register
  täcker bara svenska företag." with a hint to save by name and VAT
  number; alternate readings offered as one-click searches when the
  first found nothing.
- nameQuery strips stacked legal-form suffixes ("AB (publ)").
- The queue builds itself whenever the books hold counterparts it has
  not seen, not only on a first visit; the toast only appears when
  something was created.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): take a text-derived VAT number only on the expense side

A customer's VAT number steers reverse charge on outgoing invoices, so it
must come from a document or a person, never from a text heuristic. A
supplier's is informational and may still be read from the voucher text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:43:41 +02:00
22b98e0a3b feat(parties): fetch registry facts from SCB into the dossier, with a picker for parties without an org number (#2258)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(parties): retry label on the load-failed state

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 '<name>, 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(parties): do not repeat the county when it equals the municipality

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* chore(parties): move record_party_facts after the queue migrations

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(parties): move record_party_facts to a version after tonight's collisions

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:16:59 +02:00