a4ceaafa4f39ef8a78f0927aeb98ff698fd2df65
1384 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a4ceaafa4f |
feat(inbox): per-item underlag anchoring status and a daily reconcile cron for stranded underlag (#1548) (#2012)
* feat(invoice-inbox): per-item underlag status and daily reconcile of stranded booked items (#1548) The inbox derives "booked" from the matched transaction's verifikat, but that says nothing about whether THIS item's document reached it: a link that failed at propagation time, or a document anchored to another verifikat, read as booked while the verifikat sat without its underlag (BFL 5 kap 6-7 §). GET /items and /items/:id now also emit underlag_status (anchored | unlinked | anchored_elsewhere) from one batched document_attachments read; the workspace keeps divergent items in "Att göra", drops the booking bridge for them (the book routes 409 on a booked transaction) and shows one explanatory line with a link to the verifikat. The backfill script's loop moves into lib/transactions/ inbox-underlag-reconcile.ts and runs daily from a new extension-owned cron (vercel.json plus the generated Docker crontabs): transient link failures heal without an ad-hoc script run, permanent conflicts are counted in one summary, and each repaired transaction leaves an InboxUnderlagReconciled row in behandlingshistorik. That event type is registered by migration 20260828154800: processing_history.event_type has an FK to processing_event_types, and the script's previous InboxUnderlagBackfilled type was never registered, so its appends had always failed silently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(invoice-inbox): address review findings on the underlag reconcile (#1548) Findings 1, 3, 6 (scan cap starves the tail): the reconcile no longer caps the read. The matched-unconsumed candidate set holds permanent residents (samlingsverifikat siblings, anchored-elsewhere items) that never leave it, so a uuid-ordered read cap would revisit the same 1000 rows every night and never reach a stranded item sorting past the cut. The scan now pages through every candidate (four columns per row) and maxItems bounds the WORK: at most that many unlinked (or unreadable) items are propagated per run; already-anchored, anchored-elsewhere and locked items are counted from the pre-state without a propagation or budget. Items past the budget are counted as deferred and truncated is logged at warn level. Findings 2, 5 (false "linked automatically" promise for locked periods): resolveUnderlagAnchoring reads the fiscal period lock state of the verifikat for every unlinked item and reports unlinked_locked when is_closed or locked_at is set, the same pair enforce_period_lock_documents checks. The reconciler counts it separately (unlinkedLocked), never propagates it and never warns "still unlinked after re-run"; the rail shows a message that says the period must be unlocked first. Findings 4, 7 (absent anchoring read as booked): the list and detail enrichment emit underlag_status 'unknown' when the helper could not read the document row, and the workspace treats any status but 'anchored' as divergent (stays in Att göra, no booking bridge, own message). classify() counts a repair only when the pre-state was explicitly unlinked, so an unreadable before-read never earns an InboxUnderlagReconciled event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(invoice-inbox): address round-2 review findings (#1548) 1. [minor] Round-1 fix dropped propagation for transactions whose inbox items already read anchored, so the pinned-document leg (transactions.document_id) was never repaired and settled items never received their created_journal_entry_id stamp, staying in the scan and inflating alreadyAnchored every night. reconcileCompany now propagates every stranded transaction that has an unlinked (budgeted) item or an anchored / document-less item, outside the maxItems budget: the helper is idempotent and the stamp shrinks its own population. Locked-only and anchored-elsewhere-only transactions stay skipped. Counting and the behandlingshistorik trail are unchanged (anchored items keep their pre-state verdict, no event). Tests updated and a new case pins the anchored-item plus document-less-item transaction: propagated, no after-read, no history. DECISIONS line amended. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ad8566f1ae |
feat(settings): per-company data-analysis opt-in gating the calibration corpus (#1346) (#2007)
* feat(settings): per-company opt-in for data analysis of bookkeeping outcomes (#1346) Adds company_settings.data_analysis_opt_in (default false, no grandfathering) and gates every path that reads bookkeeping outcomes across companies on it: POST /api/agent/categorize/outcome stops writing calibration samples for companies that have not opted in, and the backtest / calibration-fit scripts filter to opted-in company ids. One helper (lib/company/data-analysis.ts) is the single gate for future analysis paths. A toggle on Inställningar > Företag states plainly what is analysed (proposed vs booked account, amount, confidence; no free text, no personal data) in sv and en. The flag is UI-only by design: consent is a human action, so it is absent from the v1 REST / MCP settings pick lists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(settings): make data-analysis consent copy true for the backtest path (#1346) Addresses adversarial review findings on PR #2007: - Findings 1-3 (consent narrower than the gated processing): the flag also gates scripts/backtest-categorize.ts, which re-runs transaction descriptions, merchant names and matched underlag through the model. The sv/en toggle help and disclosure now state that explicitly as "evaluation runs" and no longer claim that free text or underlag are excluded. The migration header and COMMENT, the lib/company/data-analysis.ts docstring, the backtest script header and the DECISIONS line say the same. Kept the gate (un-gating would put the script back to reading every company with no consent at all). A test pins that both locales name those inputs and contain no "no free text / no underlag" denial. - Finding 4 (member sees an active switch that RLS rejects): the toggle is now enabled only for owner/admin, matching the company_settings update policy; the disclosure says only administrators can change the choice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(scripts): address round-2 review findings (#1346) 1. [minor] Opted-in company filter was an unbounded PostgREST `in` list in the URL (scripts/fit-categorize-calibration.ts, scripts/backtest-categorize.ts). Both scripts now read the opted-in ids through a shared, paginated helper (listDataAnalysisOptedInCompanyIds, fetchAllRows so the pre-fetch no longer caps at 1000) and query per chunk of 100 ids (chunkCompanyIds). The fit script pages each chunk on the id PK; the backtest merges per-chunk results and re-cuts to the N most recent overall. Early exit on zero opt-ins is kept. Pinned with tests in lib/company/__tests__/data-analysis.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(scripts): coerce a null transaction description in the backtest (#1346) The typed row from the chunked consent query made description nullable, which TransactionForSelect does not accept; fall back to the original description or an empty string, as the untyped row did implicitly before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
33a58bec51 |
fix(webshop-orders): shared effective-rate helper and order-context refusal for the rate-0 slot (#1912) (#2008)
* fix(webshop): share rate classification and check rate-0 order context in bulk book (#1912) The bulk revenue template's guard copied fetchDynamicVatAccounts' effective-rate precedence (explicit momssats > treatment > class-3 number+name inference), so the two could drift. Both now call one exported helper, resolveEffectiveVatRate, and a sibling resolveRevenueVatBox resolves the momsdeklaration box for a revenue account (treatment ruta first, then the static BAS map). The rate-0 slot also ignored order context: a domestic 0% order could be routed to an export account (ruta 36) and vice versa, misstating rutor 35-42 with no VAT amount to catch it. The sweep now refuses, per order, a 0% bucket whose billing country contradicts the chosen account's box: ruta 36 vs SE or an EU country, ruta 40 vs SE, ruta 35/38/39 vs SE or a non-EU country. Unknown country (Shopify), domestic boxes (42/41/07) and unclassified accounts are unchanged; the domestic-account + foreign- country direction stays advisory in the dialog. Item 1 of the issue (require a positive momsfri/export/EU classification for the slot) is deferred: most such accounts are unconfigured today and the strict rule needs a configure path first (DECISIONS.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(webshop): address review findings (#1912) - Finding 1: the rate-0 context guard keys on customer_country, which the WooCommerce sync stores from the billing address; the goods boxes 35/36/38 follow the delivery destination, so a Swedish-billed order shipped outside the EU is a legitimate ruta 36 export the sweep refuses. Soften the WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH copy (sv/en) to say the check is based on the billing country and the account may still be right for the delivery address, and ask the user to confirm rather than change the account. Document the limitation in the route comment; storing shipping country in the sync is a follow-up. Test pins the new wording. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
22f0647d6c |
feat(bookkeeping): make inline rattelse discoverable on the verifikat page (#1554) (#2011)
* feat(bookkeeping): make inline rättelse discoverable on the verifikat page (#1554) A user who wanted Fortnox-style "stryk rader" went looking on the verifikat page and concluded the feature did not exist: since #1739 every correction action sits behind an icon-only ⋯ menu, nothing says which correction track applies when, and the struck-line marker showed only a date. - Promote "Stryk rader i verifikatet" to a visible outline button for a posted, non-structural entry whose period the period-status endpoint reports as open; the ⋯ item stays so the menu remains the complete list. - Add the convention-7 "?" after the H1 with the two-sentence track rule: inline rättelse while the period is open and unlocked, storno once it is locked, closed or declared. - The rattelse-log route now returns an additive actor_label resolved from profiles via the service client (same precedent as behandlingshistorik); struck rows read "Struken {date} av {actor}" and the Rättelsehistorik rows carry the actor beside the date. No change to the RPCs, the log table, or which corrections are legal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(bookkeeping): address review findings on inline rättelse discoverability (#1554) - Tie the un-awaited period-status fetch to the fetchData run that issued it (monotonic request ref), so an earlier response resolving last can no longer set periodStatus='open' for an entry in a locked period and promote the "Stryk rader" button the RPC would refuse. - Align the "?" help copy with what the system enforces: storno is the only path once the period is locked or closed; a VAT-declared month is stated as a caveat (same wording as the StrikeLinesDialog explainer), not as a gate the product does not apply. Both sv and en. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ca93ef3fb6 |
fix(salary): surface employee-save failures and a typed 503 for the missing encryption key (#1996) (#2009)
* fix(salary): surface employee-save failures in the dialog and type the missing encryption key (#1996) Pressing Spara in "Ny anställd" could fail without any feedback: a thrown fetch or a non-JSON 5xx body escaped handleSubmit before setSaving(false) ran, leaving the button stuck on "Sparar..." and the dialog silent. Even when the toast did fire, the Radix modal aria-hides the root-layout Toaster, so assistive tech (and the E2E driver that found this) heard nothing, and the requestId support needs was never shown anywhere. - NewEmployeeDialog: fetch + parse run in a never-throwing helper, saving is released in finally, the body is parsed with json().catch(() => null) so an HTML/plain-text error page still maps through the HTTP-status map, and the failure is rendered inline (role="alert" in the footer) with "Ärende-id: <requestId>" next to the single destructive toast. - personnummer.ts: the production "key missing" throw now carries the registry code PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED, and the SALARY registry gains a 503 entry naming PERSONNUMMER_ENCRYPTION_KEY with a "contact support" message and a remediation hint. withRouteContext emits the typed envelope automatically instead of INTERNAL_ERROR 500, which read as transient and invited retries that can never succeed. - Tests for the route (401, 400, 503 with requestId and no insert), the key guard, the registry entry, errorResponse dispatch on a coded Error, and getErrorMessage locale handling of the new envelope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(salary): address review findings (#1996) - NewEmployeeDialog: fall back to the X-Request-Id response header when the body carries no error.requestId. The route hand-builds its 409 (duplicate personnummer) and generic insert-failure 500 bodies as flat strings, so the inline "Ärende-id" line was hidden for exactly the DB-failure class the issue names; withRouteContext sets the header on every response. - Route tests pin that the 409 and 500 insert-error arms carry X-Request-Id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
89ce837947 |
fix(arsredovisning): losses lost their minus sign in the PDF (#2013)
* fix(arsredovisning): losses lost their minus sign in the PDF
toLocaleString('sv-SE') formats negatives with U+2212 MINUS SIGN, which
react-pdf's built-in Helvetica (WinAnsi encoding) has no glyph for, so the
sign was silently dropped: a loss rendered as a profit on 'Arets resultat'
and 'Summa fritt eget kapital' while all totals stayed correct. Reported by
a user whose -4 684,24 kr loss displayed as +4 684 kr.
Add formatPdfKronor (absolute value + ASCII hyphen-minus) and route the K2
and K3 PDF templates and the PDF-bound note builders through it. Display
only; no ledger or iXBRL changes (the iXBRL writer already handles signs
via the sign attribute and its own presentational minus).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CBPBCdBr5P75MQCt4qcnE
* fix(arsredovisning): pin PDF group separator to U+00A0 across ICU versions
Newer CLDR data groups sv-SE thousands with U+202F NARROW NO-BREAK SPACE,
which WinAnsi Helvetica also lacks: digits would silently run together the
same way the minus sign was dropped. Normalize the separator to U+00A0 in
formatPdfKronor so the rendering does not depend on the runtime's ICU.
Addresses the PR Agent reviewer-guide finding on #2013.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CBPBCdBr5P75MQCt4qcnE
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
b4b11c33ca |
fix(arcim-migration): require a completed SIE import for Fortnox too (#2000) (#2006)
* fix(arcim-migration): require a completed SIE import for Fortnox too (#2000) POST /migrate refused entity import without a completed SIE import for every provider except Fortnox, on the assumption that the wizard always runs SIE-over-API first. The wizard lets the user uncheck "Bokföringsdata (SIE)" while keeping customers, suppliers and invoices checked, so a Fortnox company could end up with subledgers but no chart of accounts and no opening balances: the incomplete bokföring the guard exists to prevent. The guard now applies to all providers as "a completed SIE import must exist for the company", not "must be part of this run", so an entities-only re-run after an earlier full migration still works. For providers that serve SIE over the API (Fortnox, Briox, Björn Lundén, WINT) the 409 message points at the wizard checkbox instead of "ladda upp en SIE-fil". The options step shows one attn line and disables "Starta migrering" when SIE is unchecked, never imported, and any API import is selected, so the user sees the rule before the run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(arcim-migration): address review findings (#2000) - Findings 1, 3, 4 (stale hasSieData on retry): the options step now derives "SIE already imported" from the preview OR this session's successful /import-sie results, so an entities-only retry after a phase-2 failure is no longer blocked with a false "not imported yet" hint and a forced replace-mode ledger re-import. - Finding 2 (company-info-only run gated): importCompanyInfo is dropped from the wizard gate and POST /migrate skips the SIE guard when every entity flag is false. Company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply. Tests pin both the company-info-only pass and the block when any entity flag is set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7993700589 |
fix(enable-banking): only hard-warn about sibling connections at observed one-session banks (#2005)
* fix(enable-banking): only hard-warn about sibling connections at observed one-session banks The same-bank warning dialog fired for every bank whenever the user's other companies held a connection, claiming the siblings could stop syncing. That is only true for banks binding one active AIS session per PSU (observed: SEB). Prod shows Handelsbanken tolerates at least four concurrent sessions syncing daily, and the generic warning made a multi-company user abandon a legitimate renewal of an expired Handelsbanken connection. The decision now lives in a pure, tested module (connection-warning.ts): - One-session banks (SEB): hard warning on fresh connect and renewal alike. - Other banks, renewal: no dialog; the connection already coexisted. - Other banks, fresh connect: calm confirmation so a user who meant to renew notices they are about to create a second connection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PUKATngZZzqkoDesyxKR3 * fix(enable-banking): fail closed for banks with unknown session policy, exempt shared-session siblings Skeptic findings on the first cut: - Unknown banks were treated like verified multi-session banks (silent renewal), turning absence of evidence into evidence of absence. They now keep the previous hedged warning on both paths; only Handelsbanken (verified 2026-08-28: 2-4 concurrent connections per user on DISTINCT session_ids, all syncing daily) gets the calm tier. - Siblings sharing the session being renewed are carried by fanOutSessionRenewal and never break, so they no longer trigger or inflate the warning; the absolute SEB claim is accurate for the remaining, genuinely separate sessions. - Company names are deduped and phrasing follows distinct company count (one company holding privat + foretag rows is "ett annat bolag"). - Null bank_name no longer throws (DB column is nullable). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PUKATngZZzqkoDesyxKR3 * fix(enable-banking): count clashing companies by id, names are display-only CodeRabbit: two distinct companies sharing a name, or one whose name fails to resolve, undercounted to "ett annat bolag". SameBankClash now carries companyId as identity; the parenthetical name list stays deduped display. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PUKATngZZzqkoDesyxKR3 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
57d4359d1a |
feat(booking-templates): per-company opt-in hiding of system templates (#2004)
* feat(booking-templates): per-company opt-in hiding of system templates Users cannot delete or hide the 26 standard konteringspaket, which clutter the settings panel and every template picker. Deletion stays off the table (shared global rows); instead a company can now hide individual system templates for itself only. - New booking_template_hidden table (insert=hide, delete=unhide), RLS gated on active company + write role; nothing hidden by default - POST/DELETE /api/settings/booking-templates/[id]/hide (system templates only; company/team templates keep their real delete path) - List route decorates rows with per-company is_hidden; pickers filter them out; the settings panel shows hidden ones in a collapsed restore section so hiding is never silent - Classified in full-archive-export exclusions (UI preference, not rakenskapsinformation) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL * fix(booking-templates): idempotent re-hide, system-only RLS insert, hidden filter in bulk-book Skeptic + CodeRabbit findings on #2004, one pass: - hide upsert now passes ignoreDuplicates (DO NOTHING): the table has no UPDATE policy on purpose, so the DO UPDATE conflict arm turned a concurrent re-hide into an RLS 42501/500; pg test pins the conflict shape - bth_insert policy additionally requires the referenced template to be an active system template (migration is unmerged, edited in place); negative pg test for company templates - BulkBookDialog excludes templates hidden by the company (was reading the table directly and ignoring hides) - panel shows the failure toast when the hide/unhide fetch itself rejects - picker category chips built from the hidden-filtered list Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5720632832 |
fix(transactions): stop inbox row markers overlapping Belopp on mobile (#2003)
* fix(transactions): stop inbox row markers overlapping Belopp on mobile The inbox row's inline markers (pre-migration marker, edited badge, 1930<->1630 badge) are shrink-0 inside a max-w-0 description cell, so on narrow viewports they overflowed the cell and painted over the amount column. Gate them behind md: (the same pattern TransactionHistoryList already uses), clip the description cell with overflow-hidden as a backstop, and surface the pre-migration context in the row foldout so it stays reachable on mobile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YQkngviNKUTAwA5dhi2ff1 * fix(transactions): keep edited-title state reachable on mobile without original name CodeRabbit review: the PATCH route can set title_edited_at while original_description is null, and below md both the inline marker (now hidden) and the foldout line (required originalName) disappeared. The foldout now opens on title_edited_at alone and falls back to a generic edited-title line when the original bank name is missing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YQkngviNKUTAwA5dhi2ff1 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
52e99295de |
fix(white-label): accept byrå-team invites before landing, so admins reach /clients (#2002)
A newly-invited byrå admin/member who signed up with email+password landed on /onboarding instead of the cockpit. Root cause: team-invite acceptance lived only in POST /api/team/accept, which the email-confirmation signup flow never reaches before the dashboard (no session for the register page's client-side accept), while the auth callback and the onboarding/select-company recovery only understood company_invitations. So the invitee's byrå membership did not exist when landing resolved, and they were funneled into creating a company. - New shared helper acceptPendingTeamInviteByToken (lib/company/pending-invites) is the single server-side implementation of team-invite acceptance. - POST /api/team/accept delegates to it; HTTP contract unchanged. - /auth/callback accepts a team invite BEFORE the silent-team check and before resolveLandingDestination runs, so an owner/admin resolves to /clients; the invite cookie is cleared on success, kept otherwise for the retry. - acceptPendingInviteByToken (onboarding/select-company recovery) tries the company path, then falls back to the team helper. - hasPendingInviteForEmail checks both invite tables, so a tokenless byrå invitee is not misread as a first-timer. No migration (team invite tables already exist). Company-invite and non-invite flows are untouched. Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
89d0e1b994 |
fix(mcp): promote gnubok_update_customer to the default tools/list catalog (#1986)
* fix(mcp): promote gnubok_update_customer to the default tools/list catalog The tool existed since #1876 but was catalogVisibility 'search', which means it appeared only through gnubok_search_tools. Claude.ai (and other list-driven clients) can only call tools present in tools/list, so on the primary connector the tool was uncallable and the reporter of #1706 read it as missing twice. - server.ts: drop the 'search' flag on gnubok_update_customer; no change to schema, executor, scopes, risk tier, or staging path - update-customer.test.ts: pin the tools/list projection (isDefaultCatalogTool) instead of the flag; keep the search test - payload-size.bench.test.ts: ceiling 63_400 to 64_100 (measured 64 043, +761 over main) with a progression bullet - README.md: note that search-only tools are not callable from Claude.ai - DECISIONS.md: supersede the 2026-08-25 keep-search-only entry Closes #1706 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * test(mcp): re-measure the tools/list ceiling after #1993 landed on main #1993 declared line_type and revenue_account on the create item schema, which moved the accounted projection to 63 761 with gnubok_update_customer promoted; ceiling 63_800 keeps the same headroom as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
17caf9d80a |
feat(mcp): article-aware invoice updates with gnubok_get_invoice round trip and rebooking preview (#1993)
* feat(mcp): article-aware invoice updates with gnubok_get_invoice round trip and rebooking preview gnubok_update_invoice items are a FULL REPLACE, had no article fields, and no MCP tool returned invoice lines, so a quantity fix rebuilt from memory wrote article_id/revenue_account null and reverted vat_rate to the customer default: revenue silently moved from the article account (3041) to the VAT-derived default, invisible in the approval preview. - gnubok_get_invoice (invoices:read, search-only): header plus every line with article_id, revenue_account, vat_rate, dimensions, editable_draft - gnubok_update_invoice lines accept article_id with the same prefill and default-set VAT adoption guard as create; permitted-set VAT gate at staging; preview carries the new lines' effective booking and a snapshot of the lines being replaced - commitUpdateInvoice scope-checks staged article ids like create does - OperationPreview: update_invoice preview (current vs new lines, header diffs, totals); create_invoice lines show VAT rate and posting account - invoicing skill points at the read-before-replace round trip Closes #1642 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(mcp): use roundOre for update-invoice preview totals so the ore ratchet stays at baseline The preview-building code in gnubok_update_invoice introduced five naive Math.round(x * 100) / 100 occurrences, tripping check:guards (naive-ore-round 627 vs baseline 622) and failing Core Build on PR #1993. roundOre from @/lib/money is the sanctioned helper and was already imported in this file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(mcp): make the invoice round trip lossless for text, ROT/RUT and accrual lines Skeptic review of #1993 found three round-trip breaks for web-created drafts edited via MCP (the exact silent-loss class issue #1642 reports): - Text rows: the update pre-gate and resolveInvoiceLineFromArticle rejected quantity <= 0 before looking at line_type, so any draft with a free-text spacer row could not be edited at all, and the natural agent recovery (drop the row and retry the FULL REPLACE) silently deleted invoice content. Text rows are now exempt from the quantity/description/unit/price gates (CreateInvoiceItemSchema parity), normalized to the zeroed stored shape, excluded from the staged totals and the VAT gate (commitCreateInvoice billableItems parity), and line_type is declared on both the create and update item schemas. - ROT/RUT: gnubok_get_invoice omitted housing_designation, apartment_number and brf_org_number, so an items replace on a ROT draft either failed AFTER approval ('Fastighetsbeteckning krävs för ROT-avdrag') or, for a schema-conformant agent, silently stripped the avdrag and the stored personnummer. The three property columns (property identifiers, never the personnummer ciphertext) are now returned per line, the deduction fields are declared on the update item schema, deduction_type rides on the current_items snapshot and the new-lines preview, and a staging-time completeness gate (arbetstyp/timmar via validateDeductionLines, fastighetsbeteckning for ROT, personnummer availability on the invoice or the individual's kundkort) surfaces the failure to the agent instead of the approver. - Declared-schema gap: revenue_account and the accrual fields were accepted on pass-through but undeclared, so a schema-conformant agent dropped a manual posting-account override or a periodisering on pass-back. They are now declared on the update item schema (revenue_account also on create; create deliberately does NOT declare deduction/accrual fields because commitCreateInvoice drops them), and the approval preview shows ROT/RUT-avdrag and the periodisering period per line. tools/list ceiling check after the two new create-schema properties: 63337 of 63400. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
273af39994 |
fix(whatsapp): company question falls back to numbered text and ignores archived companies (#1992)
* fix(whatsapp): company question survives a Meta-rejected interactive send (#1589) The linked multi-company sender in #1589 never heard back because Meta rejected the reply-button payload synchronously (HTTP 400, #131009 "Duplicate button title"): the sender belongs to two companies with the same name, one of them archived. askCompanyQuestion rolled the question back and returned not_asked, the row stayed parked as staged_awaiting_company, and the channel went silent. - Exclude archived companies wherever the channel resolves memberships (isMember, resolveCompanyTarget, loadCompanyOptions, applyCompanyChoice, the M3 greeting count), same inner-join filter as the middleware. - uniqueTitles: interactive button/row titles are made unique (position suffix) so two live same-named companies, or names that truncate to the same prefix, no longer trip #131009. - Numbered-text fallback: when the interactive send is rejected at send time, ask the same M6 question as plain numbered text; roll back only when that fails too. A typed digit is recorded as via='numbered'. - Drain: when the sender now resolves as 'single', rows parked behind the dead question are re-opened and kicked instead of expiring at Meta. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(whatsapp): single-company drain also clears the dead company question (#1589) Re-opening the parked rows left the conversation in state awaiting_company with company_options and the company pending_question intact, so the sender stayed behind a zombie question for up to 48h: every typed word became a company_retry re-offering the archived company, 'byt' was swallowed, and finalizeBurst could not ask about the drained receipts until the TTL sweep. - After the drain, when a company question is open in any of its shapes (awaiting_company state, kept company_options, company pending_question), clear it through the guarded updateConversation: state -> idle, options and the company pending_question deleted, other question types untouched. - Tests: the clear in its awaiting_company and post-TTL (idle + options) shapes, and its no-op for a representation question. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
57ff2eda96 |
fix(whatsapp): unknown-sender quota RPC fails open to the throttled greeting path (#1991)
* fix(whatsapp): unknown-sender quota RPC fails open to the throttled greeting path (#1599) When check_and_increment_whatsapp_sender_quota errored, handleUnknownSender logged, wrote a fail-closed trace row and returned: a transient DB hiccup silenced a first-time sender at the exact moment they were trying to link. The limiter being unavailable now falls through to the existing greeting path, whose own throttle (1 M1 per hour for text, 10-minute media burst, 3 per day, fail-closed on its own read error) and the single-use link-code claim already bound outbound volume. A thrown RPC (network) is treated the same as a PostgREST error. Over-quota (ok: false) is untouched: silent by design, decline trace kept. In degraded mode a valid code still binds and gets M3; a bad code gets the throttled M1 instead of M2, because only the quota bounds M2. The greeting dispositions carry a ' (quota limiter unavailable)' suffix so support can tell the two modes apart; suffix rather than prefix because last-event.ts matches dispositions with startsWith. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(whatsapp): answer a bad link code with a throttled M2 in degraded mode Review finding on #1991: withholding M2 while the quota RPC is down left the worst sub-path of the linking moment silent. A sender greeted with M1 inside the last hour who then sends an expired or mistyped code fell through to the greeting path, where the 1/hour rule declined M1, so they heard nothing at all; exactly the silence issue #1599 targets. M2 now gets its own small bound instead of being withheld: a new badCodeThrottled read in lib/conversation.ts (mirrors greetingThrottled, keyed on raw_payload->>template = m2_bad_code: 1 per 10 minutes, 3 per day per phone hash, fail-closed on read error). In degraded mode a bad code sends M2 when that throttle allows and otherwise falls through to the existing M1/silence path. The normal path is untouched: the short-circuit only does the extra read when the quota RPC was unavailable. The M2 trace disposition carries the degraded suffix. Tests: the replaced "withholds M2" case now asserts M2 goes out once under its own throttle with the degraded trace suffix; new cases cover a repeated bad code inside the 10 min window (silent skipped trace via the greeting throttle), the 3/day cap, and an unreadable M2 window failing closed to the M1 path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f0af4ad4ee |
fix(transactions): categorize fails closed when the verifikat cannot be created (#1990)
* fix(transactions): categorize fails closed when the verifikat cannot be created (#1947) Booking into a locked period refused the verifikat but still wrote is_business/category, so the row left "Att bokföra" and the nav badge while journal_entry_id stayed NULL (canonical worklist predicate: is_business IS NULL). The verifikat is the booking: when it cannot be created nothing is written and the request returns a typed 409 TX_CATEGORIZE_JOURNAL_ENTRY_FAILED (Swedish reason preserved, details.cause = underlying code); a null engine return maps to 400 NO_OPEN_PERIOD_FOR_DATE. Same shape on the dashboard route, the v1 single route and per item in v1 batch-categorize. journal_entry_error stays in the 200 body, always null, for client compatibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(transactions): fail closed on the engine's null return in the MCP/bulk door too Review findings on #1990: categorizeMatchedTransaction (pending-op approval, Underlag bulk-book) still wrote is_business/category with journal_entry_id NULL when createTransactionJournalEntry returned null (closed year or missing period return null without throwing), recreating the exact #1947 stranding while the tool reported success. The core now refuses before the transactions update with a structured 400 whose errorCode (PERIOD_LOCKED or NO_OPEN_PERIOD_FOR_DATE, told apart via checkPeriodLock) flows into result_data.error_code; the bulk driver skips such items with reason no_open_period. The dashboard route's null guard gets the same disambiguation: a closed covering year answers PERIOD_LOCKED (reason period_is_closed) instead of claiming the rakenskapsar does not exist, and the thrown-error branch now pairs messageSv with messageEn per the errorResponseFromCode contract. TX_CATEGORIZE_JOURNAL_ENTRY_FAILED message_en no longer embeds API-doc prose (details.cause guidance lives in remediation). DECISIONS line corrected: the MCP door was fail-closed only for thrown engine errors, not the null return. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cb9ae15d46 |
fix(storno): return stornoed bank transactions to Att bokfora (#1985)
* fix(storno): return stornoed bank transactions to Att bokfora reverseEntry() unlinked bank transactions from the reversed entry by clearing only journal_entry_id. The worklist's "unbooked" predicate is is_business IS NULL AND is_ignored = false (lib/worklist/types.ts), so the row stayed "handled": absent from Att bokfora and from the nav badge, while the storno dialog (reverse_warning) promised the opposite (#1950). The engine now resets the same triple the uncategorize paths write (journal_entry_id, is_business, category) plus reconciliation_method, scoped to rows linked to the reversed entry. Fixed in the engine so the dashboard, v1 and MCP reverse doors all agree. Closes #1950 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(storno): release bulk-booked bank rows anchored through transaction_voucher_links The #1950 fix reset transactions scoped by journal_entry_id, but bulk-booked samlingsverifikat (bulk_book_transactions RPC) anchor their N>1 bank rows through transaction_voucher_links only (journal_entry_id stays NULL), so the reset matched nothing there: all rows kept is_business = true against a status='reversed' entry, stayed out of Att bokfora and the nav badge, and is_transaction_booked() still reported them booked. The N=1 variant left a dangling link row that blocked re-booking (BULK_BOOK_TX_ALREADY_BOOKED) and kept the reconciliation bridge bucketing the row as matched. reverseEntry now deletes the reversed entry's junction rows (the same removal koppla-bort performs) and releases is_business, category and reconciliation_method only for rows left with no anchor: a remaining-links read plus journal_entry_id IS NULL guards residual bookings (main verifikat in journal_entry_id, junction row to the residual verifikat) and multi-allocated rows so stornoing one voucher never unbooks a still-booked row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(bookkeeping): restore the booked triple in fix-cash-mismatch's transaction relink The widened reverseEntry reset (#1950) now nulls is_business, category and reconciliation_method together with journal_entry_id on the linked transaction, but the fix-cash-mismatch remediation relinked with only journal_entry_id. The repaired row ended up booked (pointer at the posted clearing entry) yet visible in Att bokfora and the nav badge (worklist predicate: is_business IS NULL), the inverted #1950 symptom; booking it from the list would conflict-storno the correct clearing entry and corrupt the AR chain the route just repaired. The relink now restores the full booked triple, mirroring the match-invoice route's final update. New route tests cover auth 401, validation 400, the no-targets path, and assert both relink payloads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
533df34369 |
fix(payments): make supplier payment batch creation atomic via create_supplier_payment_batch RPC (#1989)
createSupplierPaymentBatch wrote the batch header and its items as two separate PostgREST inserts, and the active-batch recheck ran app-side before either. Two concurrent creates selecting the same invoice could both pass that check and both land an active batch without confirm_already_batched, and an item-insert failure after the header landed could leave an empty 'created' batch behind when the best-effort cancel also failed. The new SECURITY DEFINER RPC is now the single write path: it locks the selected invoices FOR UPDATE in id order, re-checks payability, amounts and active batches inside the transaction, and inserts header + items together so a constraint violation rolls both back. TypeScript keeps the shared eligibility evaluation and the msg_id minting (branding lives in TS); the service result union is unchanged so the route and UI are untouched. Closes #1503 Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4f939ebb21 |
fix(payroll): expose jämkning percentage and validity on the employee tax form (#1988)
* fix(payroll): expose jämkning percentage and validity on the employee tax form (#1913) An employee with a Skatteverket jämkning decision could not have the adjusted withholding percentage set anywhere in the app: model, API and engine supported jamkning_percentage / jamkning_valid_from / jamkning_valid_to end to end, but EmployeeTaxCard never exposed them. - EmployeeTaxCard: percentage input plus required from/to dates in the A-skatt branch; null (= clear the beslut) when emptied or when no table applies, mirroring tax_table_number. Both dates are required because isJamkningValid only applies a beslut when both are set. - Edit page: PATCH body sends the three fields as explicit values (guarded on the card having reported), card initial seeded from the employee, read-only Jämkning row in the tax section. - NewEmployeeDialog: initial tax state and POST body carry the fields. - Legacy PATCH /api/salary/employees/[id]: merged-state jämkning check (start date required, dates ordered), same rule and messages as v1 and employee-commands, gated on the PATCH touching a jamkning key. - lib/api/schemas.ts: truthful comment on the engine's both-dates gate. - i18n: salary_employee.tax_jamkning_* in sv and en. - Tests on the legacy PATCH route (400 x4, 200 x3) and the POST route. The engine is deliberately untouched; the API/MCP contract (valid_to optional) stays as is, follow-up filed in the PR body. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(payroll): jämkning keys reach the employee PATCH only when visible and edited (#1913) Review findings on #1988: the card reported null for the three jämkning fields whenever its inputs were hidden (sidoinkomst, F-skatt, FA-skatt, ej verifierad) and the edit page forwarded those nulls, so toggling sidoinkomst or fixing a phone number on an FA-skatt employee silently wiped a stored beslut (which the engine still applies for FA-skatt). The two date inputs were also natively required whenever a percentage was present, so a beslut stored via the API/MCP without valid_to (allowed by the schema) blocked the whole form on unrelated edits. - lib/salary/jamkning-patch.ts (new): isJamkningEditable() and jamkningPatch(); the keys are spread into the PATCH body with explicit values (null = clear) only when the inputs were visible and edited, otherwise omitted like every other sparse field. - EmployeeTaxCard: jamkning_touched flag on EmployeeTaxValue, set by the three handlers; required on both dates gated on it; non-blocking hint (tax_jamkning_incomplete_hint, sv + en) on a seeded beslut missing a date. - Edit page spreads jamkningPatch(tax); NewEmployeeDialog initial state carries the flag. - Tests: lib/salary/__tests__/jamkning-patch.test.ts (keys omitted for sidoinkomst / f_skatt / fa_skatt / not_verified / untouched seeded row, explicit nulls when cleared, spread shape). - DECISIONS.md: one line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * test(payroll): type the insert mock's payload so the typecheck ratchet accepts the jamkning tests vi.fn(() => ...) infers an empty parameter tuple, so insert.mock.calls[0][0] failed TS2493 under the new check:types gate (#1980) on CI. Declaring the payload parameter keeps the assertions and makes the tuple indexable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
175bb8bd92 |
fix(bookkeeping): book a negative line-pattern rounding diff on 3740 opposite the business side (#1898) (#1994)
buildMultiLineMappingResult booked Math.abs(roundingDiff) on the business side regardless of sign, so a learned line_pattern whose ratios over-allocate (three 0.3334 ratios on 100.00 kr = 100.02, diff -0.02) produced an entry off by 2x|diff|. commit_journal_entry rejected it, so the user saw a failed confirm and, since #1894, an unbalanced prefill. The 3740 leg now lands on the business side for a positive diff (under-allocation, unchanged) and on the opposite side for a negative diff (over-allocation), flipped after the mirror. computeProposalLines gets the identical rule in the same change to keep the byte-parity contract, and a 5000-amount sweep test pins engine and proposal together. Also reachable with normalized ratios: 50/50 on 100.03 kr rounds to 50.02 + 50.02. Closes #1898 Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
04406edb0f |
fix(mfa): hard-navigate after TOTP enrolment so the user is not stranded on the QR screen (#1948) (#1984)
leave() in /mfa/enroll ended with router.push(returnTo) followed by router.refresh(). Enrolling raises the session to aal2, which lib/supabase/middleware.ts only re-evaluates on a fresh document request; the push and the refresh raced, the refresh won, and the user was left on the QR screen with 2FA already active and no way forward but the address bar. Always window.location.assign(returnTo) instead, the way the same function already did for /api/ destinations and /mfa/verify does for its invite and route-handler paths. returnTo is already validated by safeReturnTo, so the unconditional hard navigation stays same-origin. Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fca57dc470 |
fix(vat): momsdeklaration defaults respect the configured cadence and persist manual changes (#1998)
* fix(vat): momsdeklaration defaults respect the configured cadence and persist manual changes The period picker re-seeded from scratch on every visit: an arsmoms user whose moms_period was never set landed on a silently guessed quarterly declaration (companies without a company_settings row bypassed every gate), and a manually chosen cadence evaporated on the next visit. - Gate the view when no company_settings row exists, matching the existing "registered but no period" gate: a declaration for the wrong period type is a compliance hazard, not a convenience. - Persist the manually chosen cadence per company (localStorage, FyPicker pattern) and restore it while moms_period is unchanged; the concrete period still re-seeds to the most recently ended one, and a changed setting discards the stored cadence. - Extract the seeding decision into lib/vat/period-selection.ts with unit tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012pQn9kC742B9R7Ggi8wdn9 * fix(vat): drop cadence persistence; the moms_period re-seed is the control Skeptic review refuted the persistence half of the previous commit twice: the render-phase localStorage restore diverged from SSR (hydration error on every visit once a cadence was stored), and restoring a manually chosen cadence that deviates from moms_period kept the filing pipeline open on the wrong period type across visits, with no downstream path validating period type against the setting. The redovisningsperiod has exactly one lawful value per company, so the mount-time re-seed from company_settings.moms_period is the self-healing control, not a bug. The settings-row gate and the extracted, tested seeding resolver stay. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012pQn9kC742B9R7Ggi8wdn9 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4f6ecad549 |
feat(white-label): invite-only signup for brand domains (#1995)
* feat(white-label): invite-only signup for brand domains
A brand domain belongs to the partner's people (founder decision
2026-08-27): only allowlisted or invited users may create an account on
an invite-only brand domain; everyone else is shown an interstitial that
sends them to the canonical Accounted signup.
- brands.signup_mode ('open' default / 'invite_only') +
brand_signup_allowlist (lowercase emails, team-scoped RLS, owner/admin
writes) + create_company_for_brand_signup RPC, with pg-real coverage
- server-side gate (lib/auth/brand-signup-gate.ts) enforced on every
signup path: email signup moved to POST /api/auth/signup (the browser
used to call GoTrue directly, so a client-side check would be
bypassable), BankID gated in /bankid/complete, Google covered by the
dashboard layout's brand-domain bounce
- company invites bypass the allowlist: the invite is the authorization
- register page interstitial on gated brands (no email in the outbound
URL), sv+en strings
- dashboard layout bounces non-belonging sessions off gated brand hosts
to the canonical domain (navigation rule like WL-01, not a security
boundary)
- allowlisted signups' onboarding-created companies attach to the
brand's byra team via the new RPC, so WL-01 homes them on the brand
domain; the allowlist entry recorded by an owner/admin stands in for
the WL-15 admin gate
- byra cockpit page /clients/access + /api/clients/signup-access to
manage the mode and the allowlist
All existing brands default to 'open': behavior is byte-identical until
a brand is flipped to invite_only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4
* fix(white-label): rollback brand-signup company with the service client
Skeptic (correctness) found that a brand-signup company created under the
service role rolled back with the cookie-session client: `companies` has
RLS and no FOR DELETE policy, so the delete was a silent 0-row no-op,
stranding a member-less ghost company on the partner's byra team. Pass an
optional rollbackClient to createCompanyCore and hand it the service
client on that path; user_preferences.active_company_id then clears itself
via its ON DELETE SET NULL FK once the company row is actually deleted.
Also map a validateBody 400 (flat envelope, no code) on the register page
to the specific email-invalid field message instead of the generic one,
since the client already pre-gates password strength.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4
* fix(white-label): fail-safe brand lookup, pg-test seed, anonymize fixtures
Second resolve-pr cycle: skeptic + CodeRabbit findings and a green-up.
- Fail safe on a brands-table error (CodeRabbit CWE-285): the gate treated a
failed resolveBrandByHost as an unbranded host, opening invite-only signup
during a transient DB blip. resolveBrandResultByHost now distinguishes
"no brand" from "lookup failed"; the gate returns lookupFailed and the
email + BankID routes answer 503 (retry), never creating an account.
- pg-real: the RLS delete test seeded its row inside withUserContext, which
always rolls back, so the owner DELETE saw zero rows. Seed on the superuser
pool instead.
- Anonymize every test/fixture brand to the repo's existing synthetic
placeholder (Siffra / app.siffra.se): no real partner names in code.
- SignupAccessManager: functional setData updates so a concurrent mode
toggle and an add/remove do not clobber each other's snapshot (CodeRabbit).
- Route a transient-error message through i18n instead of the raw envelope
(raw-user-error guard); new register.error_temporary sv+en.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4
* test(white-label): anonymize new signup-gate fixtures; log oracle residual
Rename the placeholder brand in the four new brand-signup test files to a
clearly-fake, partner-unrelated name (Testbrand / app.testbrand.example);
the previous placeholder echoed a real partner. Scoped to files this PR
creates; the repo-wide legacy placeholder is left for a separate cleanup.
Also record in DECISIONS.md that the feature ships accepting the
low-severity allowlist-enumeration residual (captcha-free 403 vs 200 on
the signup endpoint), with rate-limiting as the follow-up option.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
325c827322 |
test(mcp): run the tools against a real PostgREST, not a fake supabase (#1983)
All 100 files in extensions/general/mcp-server/__tests__ fake supabase. query-journal.test.ts says out loud that its query chain is "exercised by the live MCP smoke test", and no such test exists in CI. So the PostgREST grammar of 157 tools, every .select() column string, every resource embed, every or=(...) form, is gated by nothing and fails first in production. pg-real cannot cover this: it holds a pg Pool and writes SQL, and none of that grammar is resolved by Postgres. It is resolved by PostgREST at request time. Adds a tool-pg vitest project, a docker-compose stack, a reset script that replays every migration the way the pg-real CI job does, and a CI job. The first sweep covers 74 read tools and finds no malformed query, across 87 real requests. That number is honest rather than impressive: with an empty argument set many tools bail before querying. Per-tool fixtures are what deepen it, and this harness is what makes writing them worth the effort. Includes a self-test that injects a bad column and asserts the harness detects it. That is not ceremony. It caught this file passing green while exercising nothing, twice: once locally where supabase-js prefixes /rest/v1 onto a bare PostgREST that does not serve it, and once on CI where Node 20 has no native WebSocket, so every client construction threw and was swallowed by the per-tool catch as a domain refusal. The client is now built once outside that catch, the proof-of-life assertion counts real requests instead of being trivially satisfiable, and realtime gets an inert transport. Also excludes .next from all three vitest projects. These projects override vitest's default excludes, so a local `npm run build` leaves a traced copy of the repo that gets collected as a second set of test files. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5fe3ae71a1 |
feat(mcp): surface documents that are attached to nothing on the attention resource (#1979)
A document_attachments row is reachable from eight places. A row referenced by none of them is stored, retained for seven years under BFL, and connected to no bookkeeping at all. Nothing surfaced those, so they accumulated: 4 497 across 210 companies, 481 of them in the preceding week. The naive predicate is a trap. Without a mime filter the same query returns 15 806 rows, and 11 309 of those are archived PSD2 bank-API responses that are unlinked by design. Putting them on an orientation surface would hand an agent eleven thousand items of work it must not do, which is worse than showing nothing. So the rule is an allow-list of the mime types an underlag can actually be. Measured on production: application/json was 11 309 of 11 309 PSD2 archive, and pdf/png/jpeg/heic were 0 of 4 495. The split is clean, and an allow-list keeps the next machine-payload format out by default rather than after someone notices it leaking. Two passes, mirroring fetchPurchasesWithoutUnderlag: the indexed column filter first, then eight reference lookups that run only when candidates exist, so a company with none costs exactly one query. The scan cap is set by URL length rather than table size, because every candidate id is echoed back through those eight .in() lookups. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
304baf1089 |
chore(ci): ratchet TypeScript errors, because npm test does not typecheck (#1980)
Vitest transpiles and discards types, so a type error passes all 18 000 tests and only surfaces in npm run build several minutes later. That happened twice on 2026-08-27: a widened union in the MCP server that a second declaration in lib/events/types.ts still contradicted, and an interface that would not assign into Record<string, unknown>[] because interfaces have no implicit index signature. Both were caught by the build. Neither was caught by the tests, which is the wrong order to learn it in. This is not just a faster copy of the build job. tsc --noEmit also covers __tests__ files, which the Next.js build never compiles, and that is where all 539 baseline errors live. Baselined per FILE rather than per error code, unlike the lint ratchet: the legacy errors sit in a handful of old test files and TS2322 is common enough that a code-keyed budget would let a real regression hide behind a legacy fix somewhere else. Measured: 36s cold, which is what CI pays, and 4.4s warm locally. Verified the gate fires by introducing a deliberate type error and watching it fail with the exact location, then restoring. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e41cf50afe |
fix(bookkeeping): verifikat views show company account names for off-catalog accounts (#1997)
* fix(bookkeeping): verifikat views show company account names for off-catalog accounts Verifikat views (entry detail, JournalEntryList, StrikeLinesDialog, CorrectionPreview) rendered AccountNumber without a name source, so accounts outside the BAS 2026 catalog (typically SIE-imported, e.g. a legacy 1580) showed a blank Benamning even though the company's kontoplan has the name. AccountNumber now falls back to a session-cached map of the company's own chart-of-accounts names (one fetch per page load, inactive accounts included for historical verifikat). Precedence: explicit name prop, then the company map, then the BAS reference name, keeping the PR #1968 rule that user-editable chart rows win over hardcoded copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1rcRYRM8DyVp48mp5NNVc * refactor(bookkeeping): read company account names via the shared reference-data cache The check:guards raw-reference-fetch ratchet rightly rejected the hand-rolled session cache: lib/reference-data/hooks.ts already provides useAccounts with a per-company SWR cache seeded by the dashboard layout. AccountNumber now derives the company name from useAccounts(false) instead, and the bespoke hook and its tests are removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1rcRYRM8DyVp48mp5NNVc --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
12ce693eb6 |
feat(mcp): make search-only read tools reachable, and put the payload ceiling into reverse (#1976)
* feat(api): surface the registry's worked examples in the OpenAPI spec and generated skill EndpointDefinition.example is required and every one of the 125 v1 endpoints populates example.response, but generateOpenApiSpec() never emitted it. The examples reached only the docs markdown builder, so /api/v1/openapi.json carried none and the generated skills/accounted-api had zero json blocks in all 12 reference files: every agent reading the spec or installing the skill got schemas with no concrete body. Emit example on the application/json media types (request body and 200 response) and teach the portable renderOperationMd to print it as a fenced json block. 178 worked examples now reach the skill. SKILL.md is unchanged: the examples land in the on-demand reference files, not the entry file. Attached to JSON media types only, so a multipart body and a binary application/pdf response do not advertise an example they cannot send. Adds the one missing example.request (currency-revaluation) so the new exhaustive coverage assertions hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): emit Retry-After on a v1 429 so the documented contract is real The published accounted-api skill has told agents to honor Retry-After on a 429 since it shipped, but no /api/v1 route ever sent one: the wrapper's auth failure path early-returns through v1ErrorResponseFromCode, whose finalize() set only X-Request-Id and Gnubok-Version. Unattended clients had nothing to pace against and had to back off blindly. 60 seconds is an exact upper bound rather than a guess: the rate limiter is a fixed one-minute tumbling window per key row and the limited branch does not slide it. The value moves into an exported constant next to that limiter, so the MCP server's hardcoded '60' now reads from the same place. Also corrects the withApiV1 doc comment, which claimed step 8 stamps X-RateLimit-Limit. It never did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(mcp): guard the tools/list payload for the namespace new installs get The payload ratchet only ever serialized the gnubok_* projection. The accounted_* projection is inherently larger (every tool reference gains 3 chars, ~209 tokens across the default catalog) and CLAUDE.md points new MCP installs at exactly that namespace, so the payload a new user's client receives was never measured. It had already drifted ~90 tokens past the 63.4K ceiling while the guarded number sat comfortably under it. Measure both and assert on the larger. The ceiling moves to 63.6K to cover the real worst case; this buys no new catalog surface. A second test pins the direction of the delta so Math.max cannot silently stop describing reality. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(mcp): make search-only read tools reachable, and put the payload ceiling into reverse DECISIONS.md records on 2026-08-26 that gnubok_reconcile_match had to be promoted back into the default catalog because "a search-only tool is uncallable on Claude.ai". That is a client-side limit, not a server one: the tools/call dispatcher has always resolved names against the whole tools array, and isDefaultCatalogTool gates only what tools/list shows. So catalogVisibility: 'search' was unusable as a payload lever for reads, and the ceiling could only ever go up. gnubok_call_tool gives such a client one visible name to forward through. It is a rewrite in the dispatcher rather than a forwarding wrapper: {tool, arguments} is rebound to the inner tool BEFORE resolution, so the scope check, unknown-argument guard, company routing, test-key write block, staging _meta and telemetry all apply to the real target instead of being bypassed. Reads only; a write must be named directly so its approval contract stays visible. Alongside it, gnubok_get_agent_briefing's outputSchema drops 7743 to 4565 chars. Four sub-schemas whose interiors were documentation rather than contract are condensed to a permissive object plus a fuller description; agent-briefing.test.ts already pins their runtime shape, so nothing is left unguarded. Net on the guarded (accounted) projection: 63 491 to 62 942 tokens, with the new tool included. The ceiling moves 63.6K DOWN to 63.1K, the first tightening in that ledger, and the note now says to demote a read before proposing a bump. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3447da027a |
feat(api): agent-substrate quick wins: worked examples in the spec, honest Retry-After, and a payload guard that covers the namespace new installs get (#1974)
* feat(api): surface the registry's worked examples in the OpenAPI spec and generated skill EndpointDefinition.example is required and every one of the 125 v1 endpoints populates example.response, but generateOpenApiSpec() never emitted it. The examples reached only the docs markdown builder, so /api/v1/openapi.json carried none and the generated skills/accounted-api had zero json blocks in all 12 reference files: every agent reading the spec or installing the skill got schemas with no concrete body. Emit example on the application/json media types (request body and 200 response) and teach the portable renderOperationMd to print it as a fenced json block. 178 worked examples now reach the skill. SKILL.md is unchanged: the examples land in the on-demand reference files, not the entry file. Attached to JSON media types only, so a multipart body and a binary application/pdf response do not advertise an example they cannot send. Adds the one missing example.request (currency-revaluation) so the new exhaustive coverage assertions hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): emit Retry-After on a v1 429 so the documented contract is real The published accounted-api skill has told agents to honor Retry-After on a 429 since it shipped, but no /api/v1 route ever sent one: the wrapper's auth failure path early-returns through v1ErrorResponseFromCode, whose finalize() set only X-Request-Id and Gnubok-Version. Unattended clients had nothing to pace against and had to back off blindly. 60 seconds is an exact upper bound rather than a guess: the rate limiter is a fixed one-minute tumbling window per key row and the limited branch does not slide it. The value moves into an exported constant next to that limiter, so the MCP server's hardcoded '60' now reads from the same place. Also corrects the withApiV1 doc comment, which claimed step 8 stamps X-RateLimit-Limit. It never did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(mcp): guard the tools/list payload for the namespace new installs get The payload ratchet only ever serialized the gnubok_* projection. The accounted_* projection is inherently larger (every tool reference gains 3 chars, ~209 tokens across the default catalog) and CLAUDE.md points new MCP installs at exactly that namespace, so the payload a new user's client receives was never measured. It had already drifted ~90 tokens past the 63.4K ceiling while the guarded number sat comfortably under it. Measure both and assert on the larger. The ceiling moves to 63.6K to cover the real worst case; this buys no new catalog surface. A second test pins the direction of the delta so Math.max cannot silently stop describing reality. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c0ecb34a2b |
fix(company): migration reset no longer blocks on existing vouchers, sequences, or invoices (#1977)
* fix(company): migration reset no longer blocks on existing vouchers, sequences, or invoices The 2026-08-18 eligibility rule stopped the archive-and-replace reset before the first journal entry, voucher sequence, or invoice. The reset deletes nothing: the source stays a write-closed, downloadable retention container, and the unchecked Radera foretag path already produced the same outcome without any of those guards, so the blockers only led owners into a dead end (Carrierstories, 2026-08-26). External-state blockers are unchanged. Closes #1916 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(decisions): move the migration-reset entry to the end of the log Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
dfed55cb6c |
feat(periods): undo klarmarkera so an externally closed year can be reopened (#1978)
markPeriodClosedExternally ("klarmarkera") closes and locks an imported
year without a closing entry, and nothing could reverse it: unlockPeriod
refuses closed periods and the SIE replace flow refuses closed or locked
years. An owner who klarmarkerade five imported years and then found the
prior-year SIE file was wrong had no way back (Forsslund Systems,
2026-08-27).
reopenExternallyClosedPeriod reverses the mark while the closed state still
comes from klarmarkera (closed_externally set, no closing entry), clears the
lock, writes the audit_log row, and emits period.unlocked. New route
POST /api/bookkeeping/fiscal-periods/[id]/reopen-external with envelope codes
PERIOD_REOPEN_NOT_CLOSED / PERIOD_REOPEN_NOT_EXTERNAL; "Öppna igen" action
and "Avslutat i tidigare program" chip in Settings > Bookkeeping > Fiscal
years; unlock and SIE replace refusals now point at that path.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
c981384a0e |
fix(transactions): keep the assistant usable through the whole booking flow (#1975)
* fix(transactions): keep the assistant usable through the whole booking flow Three gaps around the agent sheet during booking on Transaktioner: - The AgentTrigger bubble lived inside #dash-shell, so the inert that non-modal dialogs set on the shell made the assistant impossible to OPEN once a booking dialog was up. Moved outside the shell (it is position: fixed) and raised to z-[45]: above the DialogVeil (z-40) so it stays clickable, below dialog content (z-50). Under true modal dialogs Radix's body pointer-events lock keeps it dead as before. - Wide dialogs centered on the full viewport while the docked sheet (z-60) covered their right edge, hiding e.g. the Granska button. DialogContent now centers in the space left of --agent-dock-w, and the wide booking/review variants cap their width against it. - Step 1 of the flow (template picker, QuickReviewDialog) was still fully modal, so the assistant was dead there. Both are now non-modal with DialogVeil + a shared ref-counted useDashShellInert hook (also replacing the duplicated inert effects in TransactionBookingDialog and NewInvoiceDialog). Their Esc/veil-click dismissal is kept: they hold no half-filled form. Clicks in the agent sheet or its trigger never dismiss any dialog: data-agent-ui counts as inside, same mechanism as data-dialog-companion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): dialog centering reads a docked-only sheet width variable Skeptic review refuted the first cut: globals.css seeds --agent-dock-w at the 10px frame gutter on :root by design, so the 0px fallback in the new dialog centering never applied and every dialog sat 5px left of center (full-bleed dialogs clipped 5px off-screen). Introduce --agent-sheet-w, set inline by AgentSheetProvider only while the sheet is docked and removed otherwise, so the 0px fallback is real: sheet closed or floating renders byte-identical to the old left-[50%] and old max widths. Also cap the template picker and QuickReview's narrow variant, which could clip off-screen left on narrow desktops with the sheet docked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
99c94d467a |
fix(white-label): back-to-clients link points at the byra cockpit's home domain (#1973)
* fix(white-label): back-to-clients link points at the byra cockpit's home domain A byra member working a company homed on another host (e.g. a pre-byra company on canonical) got a relative /clients on the wrong host instead of their white-label cockpit. resolveCockpitHref mirrors WL-14's home rule: relative when the current host is the cockpit's home (brand domain, or canonical for a brandless byra), else an absolute URL there. Cross- host links render a plain <a> with a 'Hanteras via' hint; the hop lands on the brand host's login (per-host sessions, WL-01) and WL-14 then lands on /clients. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(white-label): show the cross-host cockpit hint as visible text CodeRabbit: title-only hints never surface on touch devices, so the expanded-sidebar and mobile external back-links now render the 'Hanteras via {domain}' line as small muted text under the label (same pattern as the switcher's foreign entries). Also corrects the comments claiming the no-company branch never renders the back-link: it can, on its cockpit/settings surfaces, where the relative fallback matches pre-change behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a860c690ed |
feat(white-label): WL-14 cockpit landing for BankID and OAuth/magic-link logins (#1972)
* feat(white-label): WL-14 cockpit landing for BankID and OAuth/magic-link logins Byra staff logging in via BankID or the Google/magic-link callback on their brand domain landed on /select-company resp. / instead of the cockpit, because those two paths bypassed the WL-14 landing rule. - Extract the rule into resolveLandingDestination (lib/company/landing-server.ts) so server code can call it without an HTTP round-trip; /api/clients/landing becomes a thin wrapper. - Auth callback: with no explicit destination, AAL1 sessions resolve the landing from the request host, degrading to / on any failure (MFA-enrolled users already get the rule via /mfa/verify). - BankID login: byra staff on their brand host get /clients; everyone else keeps the deliberate /select-company picker byte-identically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(white-label): address PR 1972 review findings - /api/clients/landing: requireAuth() directly instead of withRouteContext, which 4xxed byra staff without a company of their own (COMPANY_CONTEXT_MISSING) and silently sent the cockpit's primary persona to /select-company. MFA enforcement unchanged. - landing-server: log the byra membership query error before degrading to '/' so a persistent failure is distinguishable from no membership. - Deduplicate the clientWithTeamMembership test mock to file scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(white-label): paginate the byra membership query fetchAllRows per repo convention: PostgREST silently caps unpaginated selects at 1000 rows, which could hide a qualifying owner/admin membership. Errors still degrade to '/' with a log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b30c71086e |
feat(byra): gate the automatic cockpit landing to owner/admin (#1970)
* feat(byra): gate the automatic cockpit landing to owner/admin Plain byra members now land like regular users; owner/admin keep the cockpit landing at both decision sites (post-login /api/clients/landing and the '/' bounce). The middleware zero-company steer stays ungated: a member with zero companies has nowhere else to land. Cockpit access itself is unchanged (nav + /clients remain membership-based). Supersedes the 2026-08-05 all-members widening (DECISIONS.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(byra): keep byra members out of the first-run wizard on auto-landing Skeptic finding: a member whose auto-resolved active company is onboarding-incomplete (e.g. mid migration-reset, which repoints active_company_id itself) fell through the new role gate into /onboarding, a dead end for role member (WL-15 refuses client creation). Byra members without a picked-company cookie now go to /byra at the onboarding check, restoring the pre-gate shield. Also pins the role column into the landing route's select assertion so dropping it can't pass the mocked tests silently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
23776337c8 |
feat(onboarding): the hosted checklist closes with Connect to Claude (#1971)
Founder call 2026-08-27: the strategic split is Claude-first onboarding for brand-new businesses (no migration friction) and hosted onboarding for prior-system users, whose closing CTA now hands them to Claude with everything already connected: the MCP onboarding skill then opens with reconciliation findings and the Att göra-list instead of setup steps (shipped in #1967). The final checklist step 'Bygg din bokföringsassistent' becomes 'Anslut till Claude': the button opens claude.ai's Add-custom-connector dialog prefilled (connectorName from the brand, connectorUrl built from the page origin so self-hosted and white-label domains link to themselves). The in-app assistant calibration stays reachable from the assistant page. sv + en strings added. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5e9cd6f761 |
fix(enable-banking): unstarve the daily bank sync cron (#1969)
* fix(enable-banking): unstarve the daily bank sync cron The sync cron self-limited to 50s (no maxDuration export, so the route ran under the 60s platform default) and processed ~17 connections per day against 123 entitled active connections: any given connection only got an automatic sync every 4-7 days, and users bridged the gap by clicking 'Synka' manually, which pushed them to the back of the queue. - export maxDuration = 300 (Vercel Pro ceiling the code always assumed) - sync loop budget 50s -> 230s; health probe gets the 280s leftover - connections sync in concurrent waves of 4 with per-connection error isolation preserved - MAX_CONNECTIONS_PER_RUN 50 -> 300 (safety cap only; one run now covers the whole entitled queue) Cadence stays once daily at 05:00 UTC by design; users who want more can sync manually. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(enable-banking): serialize same-company connections within sync waves Skeptic refutation: the post-sync unattended reconciliation sweep is company-scoped, so two connections of one company syncing concurrently run two identical whole-company sweeps whose unlinked-GL-line snapshots race; both can claim the same journal entry for different bank transactions, leaving the GL short while every surface shows reconciled. Waves now fan out over company groups instead of raw connections: one company's connections sync sequentially inside a single wave slot, unrelated companies still run 4-wide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(enable-banking): re-check the sync time budget inside company groups Review finding (PR Reviewer Guide): the budget was only checked between waves, so one company with many connections could run past 230s inside a single wave and eat the health-probe and teardown margin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
98aff7c577 |
fix(transactions): keep booking dialog open while typing in the agent sheet (#1966)
* fix(transactions): keep booking dialog open while typing in the agent sheet
TransactionBookingDialog was a default modal Radix dialog; the agent
sheet is a fixed z-[60] panel portaled outside its DOM, so a click in
the assistant's text field counted as an outside interaction and
dismissed the dialog, losing the half-filled booking.
Apply the established non-modal convention (NewInvoiceDialog /
NewJournalEntryDialog): modal={false} + DialogVeil, with escape and
outside-dismiss prevented. Closing is explicit via the header X.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): restore page modality via inert while booking dialog is open
Skeptic review caught that the non-modal convention was ported
incompletely: NewInvoiceDialog pairs modal={false} with an inert effect
on #dash-shell. Without it the background page stayed keyboard-, AT-,
and (mobile nav) tap-reachable behind the veil, so a stray Shift+Tab
plus Enter could navigate away and destroy a half-filled booking.
Also corrects the comment that cited NewJournalEntryDialog as non-modal
precedent (it is plain modal).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
ff88e3de05 |
fix(bookkeeping): remove false 1580 tax-receivable label, let company account names win (#1968)
The hardcoded ACCOUNT_DESCRIPTIONS entry labeled 1580 'Fordran for skatt' with a Skatteverket explanation. That is wrong on both counts: tax receivables are 1640 (skattefordringar) / 1650 (momsfordran), and 1580 was traditionally 'Fordringar for kontokort och kuponger', which BAS has since moved to 1686 (why 1580 is excluded from our BAS 2026 catalog). Reported by a user who books card/Swish settlements there. Also flip AccountNumber display precedence to the company's own account_name over the hardcoded reference name: chart rows are user-editable data and must not be visually overridden by our copy. The tooltip keeps showing the BAS reference info. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
dae0aac26a |
fix(mcp): reconcile_match callable + two-phase efterkontroll + instant value for web-onboarded users (#1967)
* docs(plugin): starter prompt nudges the connector's onboarding guide Several E2E runs composed the first reply from tool descriptions before the skill loaded (questionnaire instead of the guided round). Four words in the published prompt point the agent at the guide from message one; the rest of the prompt stays memory-first and universal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): reconcile_match into the default catalog + two-phase efterkontroll + instant value for web-onboarded users E2E #12 (the smoothest run yet) surfaced three things: 1. gnubok_reconcile_match was search-only, so Claude.ai could not call it: the agent misread the uncallable tool as a missing reconciliation:write scope and punted the entire dont-double-book matching step to the web app. Promoted to the default catalog (the standing rule: skill-instructed tools must be callable); ceiling 63K to 63.4K documented. 2. Skill: efterkontroll split into two phases per founder direction. Step 3b runs DIRECTLY after the import commits: verify the trial balance, sanity-read the content, and PREPARE the chart (create 1630/8423/8314/6992 when the SIE lacks them) so the bank and Skatteverket connections land in a book that is ready for them. Step 4b after the connections: match SIE-covered bank rows with reconcile_match (never re-categorize), reconcile skattekontot to the öre, names, underlag, in a prioritized numbered list. 3. Skill: users who onboarded via the web app and connect the MCP with everything already set up get the reconciliation pass and Att göra-list as the FIRST reply instead of setup steps: immediate value regardless of onboarding path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b160f7290d |
docs(plugin): starter prompt nudges the connector's onboarding guide (#1965)
Several E2E runs composed the first reply from tool descriptions before the skill loaded (questionnaire instead of the guided round). Four words in the published prompt point the agent at the guide from message one; the rest of the prompt stays memory-first and universal. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c2f055b903 |
fix(bank): 90-day lookback is the default: long PSD2 requests make some banks kill the session (#1964)
E2E #11: the account picker's fiscal-year default sent a 365-day request for a 405-day-old fiscal year; Swedbank answered by TERMINATING the session: zero transactions, connection expired, no error surfaced (initial_sync_requested_from 2025-08-26, returned min/max null). 90 days worked. - Default lookback mode is now 'fast' (90 days), labeled rekommenderas on a first connect; the fiscal-year option stays but carries an inline 'vissa banker avbryter kopplingen' note when its span exceeds 90 days, and the long-range helper names the real failure mode + the SIE/CSV path for older history. - SIE drop card: stage + arm in ONE click (three clicks was one too many): after the verdict, the single button reads 'Bokför: oåterkalleligt (BFL 5 kap 5 §)' and the deliberate click commits with confirmed=true. - Skill: never re-ask an answered question; when a connect card rendered, do not paste the URL as text too. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
53c4d0d194 |
feat(mcp): approve the SIE import in the drop card + post-import efterkontroll in the skill (#1963)
E2E #10 (the first fully successful drop-card run) left the staged import stranded until the user prodded the agent, and the agent then improvised an excellent post-import audit (skattekonto reconciliation, missing 1630, over-stated payroll liabilities). Codify both: 1. The drop card now carries the approval: after staging, the button becomes 'Godkänn bokföringen' with a two-click BFL confirmation (confirmed=true armed on the deliberate second click, exactly the pending-operations widget pattern), then 'Bokfört' + a ui/updateContext pointing the agent at trial balance and voucher-gap follow-ups. 2. Skill: when the user writes after a card was shown, the FIRST call is list_pending_operations (an empty ledger does not mean the file never arrived); new Step 4b 'efterkontroll' codifies the audit pass (trial balance vs SIE, skattekonto vs 1630 with 8423/8314/6992 for ränta/avgifter, auto-created bank account names, underlag coverage, voucher gaps); memory-first extended with memory-back (save orgnr, bank, fiscal year, moms period after creation so the next conversation needs zero questions). Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
84e6b1eb17 |
fix(ui): cap DialogContent grid track so nowrap children cannot widen modals (#1962)
DialogContent is display:grid with an implicit auto column, and the auto track sizes to the widest child's min-content. Chrome counts nowrap text (truncate, whitespace-nowrap) at its full width in that calculation even though it truncates at layout time, so one long description (e.g. a candidate row in MatchVerifikationPicker) widened the track past the dialog, stretched every sibling, and clipped the right edge behind a horizontal scrollbar. grid-cols-[minmax(0,1fr)] caps the track at the content box, hardening every modal at once; callers can still override via className (tailwind-merge resolves the conflict in their favor). Verified with a headless-Chrome replica of the reported dialog: scrollWidth 696 vs clientWidth 510 before, 510/510 after. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ff84c8f316 |
fix(mcp): widget-bearing tools must be read-only: Claude.ai drops write-annotated interactive tools (#1961)
The SIE drop card kept flapping into Claude.ai's Interactive-tools list and vanishing, and the agent could never call it: every hypothesis (scope filter, tool-count cap, stale cache, schema shape) was eliminated against live data until one discriminator remained: all surviving widget tools carry readOnlyHint true and gnubok_create_sie_upload was the only one annotated as a write. Claude.ai accepts always-render widgets only on read-only tools and silently drops the tool otherwise. readOnlyHint is now true, which is also honest: the tool only mints a short-lived upload URL; the actual write is the staged gnubok_import_sie: the exact receipt_matcher shape (read-only widget tool, writes via separate approval-gated tools). A guard test pins the invariant for every future widget tool. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9bad3aee06 |
fix(build): bound Turbopack build memory so Vercel's standard container stops OOM-killing builds (#1958)
Since the white-label merge the production build dies with SIGKILL roughly every other run: the compile phase outgrew the standard build machine. turbopackMemoryEviction 'full' evicts finished tasks to the persistent FS cache after every snapshot, bounding the compile working set; cpus 2 caps the static-generation worker pool (default cores-1 full Node processes). The two govern disjoint build phases. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d8244ecaff |
feat(mcp): connect_migration: one-click card into the previous-system wizard (#1960)
* feat(mcp): gnubok_connect_migration: one-click connect card into the previous-system wizard 'Jag hade Fortnox' now gets the same feel as Skatteverket: the tool returns the migration-wizard link for the named provider and renders the connect-card widget (new migration branch: 'Hämta från Fortnox', button opens the wizard that logs into the old system and fetches all fiscal years plus invoices, customers, suppliers and documents). For visma/bokio (no API export) the instructions order the SIE drop card first and this card as the complement. Scope companies:read; skill step 3 points at the tool instead of raw wizard links; ceiling 62.4K to 63K documented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): 5-minute freshness hint on tools/list, widgets and prompts: the catalog changes with every deploy The stateless-client CacheableResult hint on tools/list, resources/read (widget HTML) and prompts/list was 1 hour. Claude.ai honors it, so for up to an hour after a deploy the connector served a pre-deploy catalog: a freshly shipped tool flapped in and out of the tool list depending on which fetch hit the client cache, and two E2E runs dead-ended on 'tool does not exist' for a tool that was live server-side. These payloads are static only within one deploy; 5 minutes bounds the stale window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5715dd3def |
docs(mcp): onboarding skill branches on the named previous system (#1959)
'Jag hade Fortnox' now has a real answer instead of one generic SIE instruction: API-connected systems (Fortnox/BL/Briox/Wint) get two offered paths (full migration wizard with invoices/customers/documents vs quick SIE drop, recommended by need), Visma/Bokio get the file-first path with the wizard as a complement after, unknown systems get the universal SIE export ask. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0f7625535b |
feat(mcp): drag-and-drop SIE import card: exact bytes via tools/call, no model in the byte path (#1957)
E2E #6: the flow ordered SIE-first correctly, but the agent never discovered gnubok_create_sie_upload, ran a local preflight, and sent the user to the web wizard again; it also rendered a duplicate generic bank card before the Swedbank-specific one. 1. New sie-drop widget (ui://sie-drop/app.html), rendered definition-level by gnubok_create_sie_upload: the user drags the .se/.sie file onto the card, the widget reads the EXACT bytes (FileReader), computes sha256 (WebCrypto), calls gnubok_sie_preflight via tools/call with file_content_base64 + sha256, shows the verdict, and on Importera stages gnubok_import_sie with the preflight's mappings. No network from the iframe, no model reproduction: byte path goes through the host bridge only, narrated into chat via ui/updateContext. 2. The inline size cap now applies only WITHOUT sha256: a hash-verified payload is byte-exact by proof, so the widget's 100 KB+ base64 passes while unhashed model-retyped content stays refused. 3. Discovery + ordering fixes: create_sie_upload/preflight/import descriptions name the card path explicitly; create_company's history_note points at the card; connect_bank description says pass bank on the FIRST call when the user has named it (the duplicate generic card came from a bare call followed by the nudged retry). Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fdb5f6f891 |
feat(white-label): byra white-label infrastructure: brands, cockpit, home domains, branded email (#1956)
* feat(white-label): brand and team-kind foundation
- brands table: one white-label identity per byra team (unique mutable
domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
chrome tone and WCAG contrast gate; no brand row = default appearance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-request brand theming, wordmark slot and source footer
- root layout resolves the brand from the Host header and injects a
server-rendered style block (light + dark), font pair classes and a
BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
for non-default entries)
- AGPL source-code footer link on login and public pages, both brands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra team invites, member management and team billing
- team invites unfrozen behind a kind gate (byra teams only, owner/admin
invite); members route handles multi-team membership; members/[id]
unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding
- every outbound mail is sent in the brand of the company it concerns:
getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
"via Accounted" fallback, canonical default) wired into invites,
payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra cockpit, home-domain rule and tab guard
- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
next deadline via the status engine, last booked) for byra team members,
who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
signpost page for companies homed elsewhere
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware app name across UI strings
- 24 message keys per locale converted to the {appName} ICU parameter,
27 call sites pass the active brand name (useBranding client-side,
getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
guard, signpost and source footer (sv/en parity verified)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(white-label): domain glossary and decision log entries
- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): lean byra cockpit sidebar with company-mode back link
Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings
In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): keep company picker in cockpit with nothing selected
The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(db): renumber white-label migrations past main and add byra settings scope
Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.
Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): cross-client nyckeltal view in the cockpit
Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra self-service brand logo and app name
New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): route root layout through the shared brand resolver
app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): automations roadmap teaser and cockpit i18n strings
The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company
After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.
The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(white-label): drop brand color theming, keep monochrome everywhere
White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook
ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(byra): drop the 'what's coming' tail from the automations intro
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins with zero companies land in the empty cockpit
Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): auth wordmark shows the brand logo alone
Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-brand favicon via brands.favicon_url
Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): wire the villkor and integritetspolicy footer links
Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popup for the team role dropdowns
The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded sender shows the brand name alone, no via-platform
Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): byra landing applies to every team member, not only owners/admins
An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded team invite names the byra, not "ett team pa <platform>"
Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar keeps cockpit mode after refresh on settings
The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): hide the active-company chip in byra-scoped settings
The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): tab guard no longer fires in the tab that initiated the switch
BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popups for every settings dropdown
SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): home-domain affinity redirect in middleware
Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): render hero brand logo at 64px on auth pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): shareable invite link and re-send for byra team invites
A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:
- persistent share-link line after invite create/re-send: ochre attn line
with a copy action when the mail did not go out, quiet muted line with
the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
by create and re-send so the two paths cannot drift
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label
Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): close the four skeptic refutations before merge
- trial seed: migration 130300 now carries the seven-key PAID body from
20260818170000 plus the byra guard, instead of silently reverting it;
pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
create_company_for_user (v1 API + MCP path), and both surfaces resolve
the default team personal-only, so a consultant's private company can
never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
longer redirected off the platform host; the signpost handles per-company
homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
(ROT/RUT work-type picker)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect
The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|