feat(parties): Förslag från bokföringen, confirmed straight into Leverantörer and Kunder (#2206)

* 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>

* 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>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-03 19:49:25 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent e2d38b0ab3
commit b996da60ee
40 changed files with 3568 additions and 12 deletions
+4
View File
@@ -1520,11 +1520,15 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-02] Stock GoTrue email-change redirects detected via a flow=email_change marker on emailRedirectTo rather than by sniffing ?message= / ?code= on every callback: GoTrue's PKCE redirect carries no type, and the same marker rides along on hook-built token_hash links, so one flag covers both link styles without touching signup/recovery/OAuth paths.
[2026-09-02] Sign-off refusals: registered the ReconciliationSignoffError codes in structured-errors with a new thrown_message_sv flag instead of returning err.message from the routes: check:guards forbids raw caught-error messages in user-visible sinks, and the registry keeps the codes discoverable for agents while the dialog still gets the runtime text (dates, amounts).
[2026-09-02] Nyckeltal "Resultat per månad" shows the exact per-month figures as an always-on list under the bars (#2198), not behind an "Anpassa" toggle: a preference would touch the type, the PUT schema, the strict preferences-body validator, the dialog and its tests for a switch nobody turns off. Per-bar compact labels are conditional on a glyph-width fit rule and fall back to the single latest label, so they never overlap. Left alone: the monthly path counts only posted entries while the year-total path also counts reversed originals (pinned as intended in tests/pg/kpi-report-aggregates-rpc.pg.test.ts), so a same-year storno makes the sum of months differ from Nettoresultat; visible as numbers now, founder call whether to align the two.
[2026-09-03] Kontakter register reads money and rhythm through parties.alias_keys joined to get_observed_parties rather than a stored per-party rollup: the numbers stay identical to the import reveal and nothing is written on read; dismissed parties keep their keys claimed so a hidden suggestion never resurfaces under Bara i bokföringen
[2026-09-03] Email-change double-submit gate is a dedicated per-user claim table + SECURITY DEFINER RPC (migration 20260903083000), not idempotency_keys and not an advisory lock: idempotency_keys requires a company_id the account-level route does not have, and a transaction-scoped advisory lock cannot cover the GoTrue call that happens outside the transaction.
[2026-09-03] Suppliers and customers get their party from a BEFORE INSERT/UPDATE trigger (link_party_on_role_write), not from each write path: 108 rows created in the day after the backfill had no party, and dialogs, v1 API, MCP, imports and provider migrations all write these tables. Private customers get a kind=person party with no number at all, so a personnummer never reaches parties
[2026-09-03] Kundorder PGRST201 fixed by hinting the three sales_order_items embeds AND by teaching scripts/checks/ambiguous-embed.mjs to parse composite FOREIGN KEY (a, b) constraints, instead of a bespoke source-scan test: the guard is the sanctioned owner of this class (decision 2026-09-01) and it missed the pair only because its parser was single-column; with the fix it derives the same 17 pairs prod reports and flags all three shipped sites on main.
[2026-09-03] Email-change double-submit gate is a dedicated per-user claim table + SECURITY DEFINER RPC (migration 20260903083000), not idempotency_keys and not an advisory lock: idempotency_keys requires a company_id the account-level route does not have, and a transaction-scoped advisory lock cannot cover the GoTrue call that happens outside the transaction.
[2026-09-03] Cursor MCP OAuth callbacks are built-in allowlist entries, exact-matched: cursor://anysphere.cursor-mcp/oauth/callback and https://www.cursor.com/agents/mcp/oauth/callback (the loopback http://localhost:8787/callback already passes the local rule). Cursor's DCR sends all three in one request and /register rejects the whole set on any unknown URI, so the Cursor path advertised in Settings never worked (reported by a byrå user 2026-09-03). Exact match, not a cursor.com prefix, so no other cursor.com path can receive codes; the custom scheme is accepted despite RFC 8252 section 8.4 because the code is PKCE-bound and the loopback form carries the same local-machine trust. Users cannot self-register the cursor:// form (the settings panel requires https), which is why it is built in. Grok Bot rides on Cursor's MCP stack and stays broken on Cursor's side (forum thread 168052, open as of 2026-09-02); nothing server-side fixes that.
[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] 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] settleInvoicePayment writes the invoice_payments row BEFORE the CAS status update and removes it in both failure branches, instead of inserting after the update: the kontantmetod cut-off reads invoice_payments only, so a paid invoice without a row is the #2019 defect itself; failing closed on the insert (voucher storno + INVOICE_PAID_BOOK_FAILED) keeps GL, sub-ledger and invoice status in step. The #2019 backfill inserts only where exactly one posted payment voucher exists (invoice_paid / invoice_cash_payment with source_id = invoice); zero or several vouchers are reported, never guessed, and every row is tagged backfill:#2019 in notes so one DELETE reverts a run.