36123cef23d81a600a00a3a0e1d1de69767ba1cb
495
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
36123cef23 |
feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget (#1751)
* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' Sovereign plan WS3 PR3: ships dark, nothing changes for hosted. - lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync, skatteverket, org_lookup, migration (services Accounted operates that a self-hosted instance cannot provide itself) + isConnectorCapability(). Separate from PAID_CAPABILITIES and outside the trial-seed trigger on purpose: a hosted company can never hold a connector grant. - lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key). Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, else the grant lookup). Self-host: local capabilities always on (FORCE_PAYWALL included, as the existing test demands); connector capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the grant lookup where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host: local paid keys + active connector keys, state 'paid' with an active connector grant else 'none' (never the hosted trial copy). - Migration 20260820122000: capability_grants.source CHECK gains 'connector', found through pg_constraint (the CHECK was declared inline and auto-named; Postgres stores IN as = ANY, matched accordingly). pg-real test: connector accepted, unknown source rejected, upsert on the (scope, key, source) identity, trial seed writes no connector rows. - Tests: self-hosted connector matrix (local all-on without DB, connector gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector keys only, bulk resolution, entitlements shape); two pre-existing tests that asserted the old "self-host holds connector keys" contract updated to the new one. Verified: full unit suite green, pg-real suite for lib/entitlements green against a local supabase/postgres with every migration applied, lint ratchet, guards. Deferred to the instance-wiring PR: adding the connector extensions to the self-host Docker preset (dead-end upsells until a key can be issued). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(entitlements): fold the self-host branch into the existing grants query One .or(scopeFilter), not two: the duplicated helper pushed the no-phantom-columns unresolvable-expression count to 380/379. Behaviour is unchanged; the self-host matrix tests still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the entitlement partition (#1747). Nothing is purchasable yet; this is the plumbing both ends need before the first manually issued key. Hosted side: - Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix, org_number, pinned instance_url, scopes, status, Stripe ids, current_period_end, per-minute rate limit, active_company_count, last_seen/synced) and connector_usage_events (per-request metering, separate from metered_events whose company_id references hosted companies). RLS on, NO policies: service role only. RPC validate_and_increment_connector_key copies the api_keys pattern (FOR UPDATE, minute window, suspended reported not counted, revoked = no row) and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role. pg-real test covers validate/count, unknown+revoked, suspended, rate limit, execute privileges per role, RLS invisibility, usage cascade. - lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts (generate/hash/validate -> 401/403/429 mapping), with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per request, 500 envelope on handler throw), /api/connect/entitlements GET + POST (records active_company_count, pins instance_url on first report, never moves a pinned one), scripts/issue-connector-key.ts (dry run unless --confirm, prints the key once + the .env lines). Instance side: - lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL default https://app.gnubok.se), sync.ts: reports the active company count and writes source='connector' grants for every company x covered scope, expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status deletes them (freeze-and-retain); network/5xx/429 leave them alone. /api/connector/sync/cron (hourly) runs it; not_configured without a key. - Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json, with reasons) + drift tests; docker/crontab.self-hosted regenerated with the hourly sync. Docs (SELF-HOSTING connector section, env templates), DECISIONS. Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests run locally against supabase/postgres with every migration applied. no-phantom-columns ceiling +1 with a reason (the bulk grant upsert). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget Sovereign plan WS3 PR5a, stacked on the connector-key infra (#1748). A self-hosted instance with a `bank_sync`-scoped connector key can now connect a bank through Arcim's PSD2 credentials; the bank session id and all transaction data stay in the instance's own database (founder decision: tokens on the instance, proxy stateless). - Migration 20260820124000: `connector_connections` (secret-free ledger: sha256 of the EB session id + account uids, service-role only), `connector_upstream_counters` + RPC `connector_reserve_upstream` (global budget under EB Annex 1 §5's 300/min, shared with hosted), and `connector_keys.limits` jsonb; validate RPC v2 returns limits. All RPCs REVOKEd from PUBLIC/anon/authenticated, GRANTed service_role. pg-real covers all of it. - EB JWT minting moved to lib/connect/upstreams/enable-banking-jwt.ts (core must not import @/extensions/); the extension re-exports it, tests unchanged. - lib/connect/hosted/{state,ledger,upstream-budget}.ts: HMAC-signed connector state (15-min TTL) so the consent redirect can use OUR registered EB callback and bounce back to the instance, no per-instance redirect URI at EB; the callback route gains that connector branch. - app/api/connect/bank/[...path]: path allowlist (aspsps, auth, sessions, accounts/{uid}/{balances,transactions}), never open passthrough. POST /auth enforces the per-company connection quota + rewrites redirect/state; reads/deletes verify ledger ownership; every upstream call takes the global budget (429 + Retry-After when exhausted). - issue-connector-key.ts: scopes default bank_sync,skatteverket (TIC out of v1), --bank/skv-connections-per-company + --sync-min-interval. - Docs (SELF-HOSTING: bank connector live), DECISIONS. Verified: 52 connect unit tests + 13 pg-real (run locally against supabase/postgres with all migrations) + EB extension suite (225, jwt relocation intact); full unit suite 15 979 green; tsc, guards, lint clean. Not in this PR: SKV broker (PR5b) and instance wiring (PR6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(connect): update ledger pg test to re-versioned migration 20260831200000 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): redact opaque path segments before usage metering; correct stale RPC-source comment GET/DELETE /sessions/{id} and /accounts/{uid}/... carry the raw EB session id / account uid in the pathname; metering persisted it in cleartext next to the ledger that stores only sha256(handle). Opaque segments (UUID, long hex, long base64url) now become ':id' before the connector_usage_events insert. Migration comment now cites the real prior RPC source (20260831190000). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): percent-encoded path segments count as opaque in metering redaction Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): PR #1751 review batch: https-only EB URL, body-covering timeout, quota reservation, delete-after-success, doc fix Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): bind the /sessions code exchange to its verified pending state; ceiling +1 Verified state signature, key/service match, and an existing pending row now precede the EB exchange; a concurrently consumed state closes the just-minted upstream session and 409s. no-phantom-columns ceiling 391 for countHeldConnections' computed .or() timestamp filter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com> |
||
|
|
cfce2de925 |
feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' (#1747)
* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' Sovereign plan WS3 PR3: ships dark, nothing changes for hosted. - lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync, skatteverket, org_lookup, migration (services Accounted operates that a self-hosted instance cannot provide itself) + isConnectorCapability(). Separate from PAID_CAPABILITIES and outside the trial-seed trigger on purpose: a hosted company can never hold a connector grant. - lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key). Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, else the grant lookup). Self-host: local capabilities always on (FORCE_PAYWALL included, as the existing test demands); connector capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the grant lookup where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host: local paid keys + active connector keys, state 'paid' with an active connector grant else 'none' (never the hosted trial copy). - Migration 20260820122000: capability_grants.source CHECK gains 'connector', found through pg_constraint (the CHECK was declared inline and auto-named; Postgres stores IN as = ANY, matched accordingly). pg-real test: connector accepted, unknown source rejected, upsert on the (scope, key, source) identity, trial seed writes no connector rows. - Tests: self-hosted connector matrix (local all-on without DB, connector gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector keys only, bulk resolution, entitlements shape); two pre-existing tests that asserted the old "self-host holds connector keys" contract updated to the new one. Verified: full unit suite green, pg-real suite for lib/entitlements green against a local supabase/postgres with every migration applied, lint ratchet, guards. Deferred to the instance-wiring PR: adding the connector extensions to the self-host Docker preset (dead-end upsells until a key can be issued). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(entitlements): fold the self-host branch into the existing grants query One .or(scopeFilter), not two: the duplicated helper pushed the no-phantom-columns unresolvable-expression count to 380/379. Behaviour is unchanged; the self-host matrix tests still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(entitlements): self-host connector gate honors only source=connector grants The trial-seed trigger (seed_trial_capability_grants) writes 30-day source='trial' rows for bank_sync and skatteverket on every company insert, self-hosts included. The partitioned self-host gate read every active grant, so a fresh self-host company held every connector capability for a month with no connector key (CodeRabbit finding on #1747, verified against migration 20260818170000). hasCapability and getCompanyIdsWithCapability now add .eq('source', 'connector') on a self-host; getCompanyEntitlements skips non-connector rows there. Hosted reads every source exactly as before (covered by a test asserting no source filter is applied). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(entitlements): pin the early-grants wave on the self-host partition, fix stale docstrings Address the review round on the readGrants() merge resolution: - Add two recording-mock tests for getCompanyEntitlements with teamId (the dashboard layout path): on a self-host the early grants read must narrow to the connector keys and carry source = 'connector'; on hosted it reads every paid key with no source filter. The in-loop source check masked a lost narrowing, so this pins the query itself. - Move the getCompanyEntitlements docstring back above the function and replace "Self-hosted holds everything" with the actual partition. - keys.ts: say that lib/connect/instance arrives with stack PR #1748 so a reader on main does not chase a module that is not there yet. - DECISIONS.md: describe the InvoiceInboxWorkspace.tsx merge resolution accurately (set to the #1753 blob origin/main carried at push time, not the merged parent's) and note that stack children must merge this branch forward. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NdxH4DGnggvqHBCmif6B16 * chore(connector): merge origin/main, re-version connector migration to 20260831170000 Migration 20260820122000 predates versions already applied to prod (latest 20260831150000); renamed to keep Supabase branching history monotonic. Test reference updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(entitlements): own-credentials seam so self-hosts running their own EB/SKV are never connector-gated Forward-ports the connector-mode seam's own-credentials half from the instance-wiring layer: a self-host with its own Enable Banking or Skatteverket credentials holds that capability outright, exactly like every other local capability. Without this, upgrading an own-credentials self-host silently killed working bank sync and SKV integrations and showed a hosted subscription upsell whose remedy does not exist for a self-host. capability_blocked copy gains a self-host variant naming GNUBOK_CONNECTOR_KEY; stale all-on comments updated at the two gate call sites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(entitlements): route the pending-op capability block through capabilityBlockedError for self-host copy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(entitlements): PR #1747 review batch: connector-grant expiry CHECK, test cleanup, DECISIONS correction - Migration 20260831180000: CHECK (source <> 'connector' OR expires_at IS NOT NULL); connector grants are a short-lived offline cache, a NULL expiry would be a permanent unlock nothing revokes. pg test added. - beforeEach vi.clearAllMocks() in the three entitlements test files. - DECISIONS entry corrected: the trial seed DOES write trial rows for bank_sync/skatteverket; what it never writes is source='connector' or the connector-only keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com> |
||
|
|
9fe37b85b5 |
feat(agents): per-key approval authority, the amount an agent may post unattended (#2079)
* feat(agents): per-key approval authority, the amount an agent may post unattended An API key gets an optional ceiling in SEK. Above it the agent may still stage the work, it just may not finish it alone: a human approves the same verifikat in the app. Default is NULL, so every existing key keeps its behaviour and turning this on is entirely opt-in. Enforced at the two places an API key reaches the ledger, and at both the refusal happens BEFORE the point of no return: - MCP: in commitPendingOperation, before the atomic claim, so the operation stays 'pending'. Behind the claim it would be caught by the generic handler, marked terminal 'rejected', and the staged verifikat would be gone. - REST: in journal-entries.commit, before commitEntry, so the draft stays a draft and the voucher sequence never advances (BFL 5 kap. 7 §). The dry run refuses too, rather than promising a voucher number the key cannot deliver. Not enforced inside commit_journal_entry: a RAISE there is swallowed by engine.ts into a retryable 500, and it would cost a DROP+CREATE on the function that issues every voucher number. Operations whose amount is only known during dispatch (batch allocation, bulk booking, the settlement link paths) fail OPEN behind an explicit allowlist. Pricing them ahead of dispatch would be a guess, and a wrong guess silently breaks batch allocation the day someone sets a limit. The allowlist is derived from what production actually stores: create_voucher carries total_debit on 1389 of 1389 rows, categorize_transaction carries amount on 2002 of 2003, create_supplier_invoice_from_inbox carries total on 208 of 228. This is a blast-radius cap, not a security boundary. A per-entry ceiling is defeated by splitting one entry into several, and an LLM will find that, so UNATTENDED_COMMIT_LIMIT_EXCEEDED forbids splitting first: one affärshändelse is one verifikat (BFL 5 kap. 6 §). A cumulative rolling-window limit is the primitive that actually bounds exposure and is left to a separate change. The guard is written NULL-first everywhere. An absent, unparseable or non-positive ceiling always means unlimited, never "block everything". Agents read their own ceiling from gnubok_get_agent_briefing instead of discovering it by burning a staged verifikat on a 403. Changing a ceiling is auditable: it now renders in behandlingshistorik (BFL 5 kap. 11 §). The audit trigger already fired on the column, but the report dropped the event because the field was not in its diff map. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(skill): regenerate accounted-api skill for the new commit pitfall apiskill:check is a ratchet: the generated reference must match the endpoint registry. Never hand-edited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(agents): pin the DB default itself, and declare the briefing field required Two review findings, both real: - the default test stored an explicit NULL, so it stayed green even if the column default changed to a positive ceiling: the one change that would silently start blocking every existing key. It now omits the column. - gnubok_get_agent_briefing documents unattended_commit_limit as always present and emits it unconditionally, so it belongs in the output schema's required list. Declined the NOT VALID constraint suggestion, with the reason recorded in the migration: api_keys is 388 rows / 768 kB in production. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(agents): name the TOCTOU window in the REST ceiling check A security scan flagged that the line sum is read before commitEntry, so a concurrent write to the draft's lines can post over the ceiling. Real, and accepted: closing it means enforcing inside commit_journal_entry, where a RAISE becomes a retryable 500 and destroys the staged operation on the MCP path. Recorded in the code rather than left implicit, so nobody later mistakes this for a hard control. A per-entry ceiling is already defeated by splitting, which needs no race; the cumulative rolling-window limit is the primitive that bounds exposure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): price the settlement and batch paths that were bypassing the ceiling A security scan flagged that known money-posting operations fail open, and it was right. The first cut priced only create_voucher, categorize_transaction and create_supplier_invoice_from_inbox, on the belief that the batch and settlement paths computed their totals only inside SQL at dispatch. Production says otherwise: the staged preview already carries the amount, because it is the number a human is shown when approving the operation. Over the last 120 days each of these is present and numeric on 100% of that type's staged rows: link_transaction_journal_entry transaction_amount 1369 rows bulk_book_transactions tx_sum 273 rows link_supplier_invoice_voucher payment_amount 55 rows match_batch_allocate total_allocated 24 rows mark_invoice_paid total 3 rows So a key with a ceiling could post any amount through the four largest settlement paths. Now priced, and the ceiling applies. Only reconciliation_match stays unpriced: it carries pair_count, which is a COUNT. Pricing off that would compare pairs against kronor, which is worse than not enforcing. link_document_to_voucher and attach_document_to_transaction move no money at all; the transaction_amount they carry is context, not a posting. Genuinely unpriceable types still fail OPEN. This control can only ever narrow what a key does, and a wrong guess at an amount blocks a legitimate commit, so guessing high would leave an agent unable to work. Adds a test that walks the whole allowlist, so a typo'd field name cannot silently make a type unpriceable again: that is exactly the hole this closes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(mcp): drop the ceiling from the agent briefing, the payload budget has no room The tools/list context-budget bench sits at 65 000 tokens and main now leaves roughly 20 tokens of headroom. An always-present field on the briefing's output schema costs about 85, so this addition alone pushed the bench red. The bench's own note is explicit that the answer is to demote a tool rather than raise the ceiling, so raising it here would be the wrong trade for a nice-to-have. Nothing is lost that matters: the operation is never destroyed when it is refused, so discovering the ceiling from UNATTENDED_COMMIT_LIMIT_EXCEEDED costs one round trip and no work. That error already carries both attempted and limit, and GET /api/settings/api-keys returns the value. Re-exposing it on the briefing is worth doing once there is budget to spend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(api): spell affärshändelse correctly in the commit pitfall Fixed in the route's registerEndpoint pitfalls, which is the source; the skill reference is regenerated from it and never hand-edited. 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> |
||
|
|
f216a60bf8 |
feat(invoices): per-line percentage discount and separate fakturamarkning (#2084)
* feat(invoices): per-line percentage discount and separate fakturamarkning User request: rabatt i procent per artikelrad, and a marking field separate from Er referens. - invoice_items.discount_percent (0-100, default 0): line_total and vat_amount are stored NET of the discount. Shared exact-ore math in lib/invoices/line-amounts.ts (gross, discount, net) used by the web builder, staged-operation commit, editor preview, PDF, and Peppol. Undiscounted lines keep the legacy unrounded qty*price byte-identical. - ROT/RUT deduction computes on the discounted net line total. - invoices.invoice_marking: printed on the PDF next to the references and mapped to Peppol BT-10 BuyerReference (marking wins over your_reference; either satisfies the BT-10 requirement). - Peppol renders the discount as a BG-27 line AllowanceCharge (reason code 95, MultiplierFactorNumeric, Amount, BaseAmount). - Editor: "Lagg till rabatt" in the row menu (same reveal pattern as ROT/RUT), Markning row next to Er referens, forval chip, review dialog shows discounts and marking. - Plumbed through v1 REST projections, MCP create/get/update invoice tools, pending-operations update path, and copy-invoice (discount copied; marking deliberately not, it is recipient-specific). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 * fix(invoices): carry discount_percent through every deduction, credit, convert and preview path Skeptic + CI findings on the discount/marking feature, one pass: - generateRotRutLines and propose-send-lines now pass discount_percent into computeDeduction: the send/credit/cash verifikat booked 1513 on the GROSS line while deduction_total, the PDF and the Skatteverket claim carried the net, stranding the difference on 1513 and pushing 1510 negative once the customer paid. Test pins 1513=3000/1510=7000 for a 20%-discounted 10 000 kr ROT line. - preview-pdf route accepts discount_percent (net totals + net-based deduction) and invoice_marking; the editor now sends the marking, so the preview equals the invoice it becomes. - Credit notes carry discount_percent (buildCreditNoteItem, v1 credit route select+insert, MCP credit executor) and invoice_marking, so the kreditfaktura face arithmetic multiplies out and shows the Rabatt column (ML 17 kap 24 §). - Proforma->invoice convert copies discount_percent + invoice_marking: the converted invoice previously failed Peppol LINE_TOTAL_MISMATCH and lost the rebate on the next builder pass. - Editor hides the discount menu in self-billed mode (the self-billed wire shape has no discount; previewed net would book gross). - MCP staging and commitCreateInvoice reject a non-number discount_percent (a string coerced past the range check but was ignored by the totals math and still stored). - Regenerated skills/accounted-api (apiskill:check CI failure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
84c8e1ce59 |
fix(inbox): never extract the receiving company as its own supplier (#2080)
* fix(inbox): never extract the receiving company as its own supplier The model sometimes reads the Kund/Kunduppgifter block of bank agreements and similar documents as the issuer, so the inbox offered to create the user's own company as a leverantor. Two layers: a supplier-direction rule in the extraction prompt (issuer, never the customer/recipient block), and a deterministic post-extraction guard that nulls the supplier block when its org number, derived VAT number, or exact name equals the receiving company's own. All AI extraction paths (upload, deferred, retry, attach-document, document-extraction, MCP) pass the company identity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(inbox): keep the own-company guard alive for photographed documents Skeptic findings on PR #2080: normalizeImageForExtraction rebuilt the input without ownCompany, so the guard never fired for HEIC/oversized phone photos (the motivating case). Spread the original input instead. Also let a provably different extracted org number outvote a name coincidence, and accept 12-digit personnummer-form own org numbers (19/20 century prefix, enskild firma) alongside the 16-prefixed organisation form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * docs(compliance): add RoPA entry for the own-company-identity lookup Compliance swarm (GDPR Art. 30): fetchOwnCompanyIdentity reads companies.name and org_number on every extraction; record the processing activity (transient in-memory comparison, never sent to the model). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(inbox): log own-company identity lookup failures instead of failing silent Compliance swarm (SOC 2 CC7.2): the bare catch in fetchOwnCompanyIdentity made a persistently broken lookup (RLS misconfig, DB outage) disable the guard invisibly. Also pin the VAT-number match across prefixed and bare digit forms in the test matrix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(inbox): surface maybeSingle query errors in the own-company lookup CodeRabbit: maybeSingle() reports RLS/query failures in error without throwing, so the failure log added for SOC 2 CC7.2 never fired for exactly those cases. Throw the reported error into the existing catch (still fail open to nulls). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6dfaa45061 |
feat(notifications): opt-in daily "nytt att bokföra" email digest (#2078)
* feat(notifications): opt-in daily "nytt att bokföra" email digest Users asked for an email when new work arrives: bank transactions that synced overnight and documents that landed in the inbox. Adds a daily 05:45 UTC cron (after the 05:00 bank sync) that emails opted-in users a per-company summary with counts only, no amounts (data-minimization stance of the kvittens/skattekonto mails). - notification_settings.email_digest_enabled, NOT NULL DEFAULT false: strictly opt-in via a new toggle in the notification settings panel - notification_log type 'bookkeeping_digest' with the claim-then-send partial unique index pattern: one mail per user per company per day - counts unbooked transactions and unprocessed inbox items created in the last 24h; empty digests are never sent - brand-aware sender + link base via lib/email/brand-sender - docker crontabs regenerated from vercel.json Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0122MznXxrLRyT96fGhfyzD4 * fix(notifications): digest review findings in one pass Skeptic + bot findings on PR #2078, resolved together: - Count queries now match the canonical worklist anchors: ignored transactions excluded; inbox items already booked directly or matched to a transaction (created_journal_entry_id / matched_transaction_id) no longer counted (skeptic: spurious digests). - Memberships sweep and member-email lookups chunk .in() id lists at 150 ids to stay under proxy URL limits (HTTP 414 at ~350 opted-in users). - Recoverable claim lifecycle (CodeRabbit): claim inserts as 'pending', flips to 'sent' only after the provider accepted the mail; a stale pending claim is atomically taken over by a later run, so a worker death mid-send no longer swallows the day's digest. New migration 20260831110000 admits 'pending' to the delivery_status CHECK. - Company name sanitized against CRLF header injection before the mail subject (compliance swarm ASVS V1.2.5), with test. - RoPA entry for the new processing activity in .compliance/ropa.yaml (compliance swarm GDPR Art. 30). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0122MznXxrLRyT96fGhfyzD4 * fix(types): admit 'pending' to NotificationLog delivery_status union Matches migration 20260831110000; surfaced by fix re-verification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0122MznXxrLRyT96fGhfyzD4 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1a3686dd45 |
feat(inbox): promote a single prominent amount into the editable total (#2073)
* feat(inbox): promote a single prominent amount into the editable total Follow-up to #2048 after founder review: the Belopp row was load-bearing for matching but read-only, so a misread amount could not be corrected, and an empty TOTALT still read as "extraction failed". - promoteSingleProminentAmount (extraction post-step, all intake paths): documentKind other/government_letter with no total and exactly one distinct nonzero prominent amount gets it copied into totals.total, stamped totalSource: 'prominent'. Multi-amount documents are left alone: picking one silently would invent a total. - provenance keeps the safety rails: matching demotes a promoted total back through the prominent-amounts fallback (0.85 discount, date guard, amountSource tag), so the nightly receipt-hunt still excludes these documents and confidence never presents as certainty. - the fields-PATCH route clears totalSource when a human edits TOTALT: a user-set amount is a verified total at full weight. - the read-only Belopp row now renders only for multi-amount documents, and filters zero amounts ("Totalt manadspris: 0 kr" noise). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(inbox): review pass: concurrency-safe fields PATCH, zero-amount predicate CodeRabbit findings on #2073: - the fields-PATCH read-merge-write could let a racing autosave restore a stale extracted_data blob (including a totalSource stamp a concurrent TOTALT edit had just cleared). The update is now conditional on the trigger-maintained updated_at; zero rows matched returns 409 and the client's next debounced save re-reads. - hasAnyExtractedField now uses the same meaningful-amount predicate as the Belopp render filter, so a zero-only prominentAmounts list no longer suppresses the retry / upgrade affordances. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8b0aa80ea0 |
feat(arcim-migration): import the Fortnox asset register during migration (#1999)
The Fortnox migration now imports the asset register (GET /3/assets + /3/assets/types) as local register rows via createAsset: category from the type's anskaffningskonto BAS class, useful life from the source's depreciation window (K2 schablon fallback), never any journal entries (values arrived via SIE; the source's depreciated-to date is recorded in notes for review of the first proposal). Sold/scrapped/voided assets are skipped, re-runs dedupe, one bad asset counts as skipped. Gated behind FORTNOX_ASSET_SCOPES_APPROVED=false until the portal registration for integration 39254 carries the Assets scope, so hosted consents are unchanged and the wizard shows an honest skipped row. Co-authored-by: pgronberg <pgronberg@users.noreply.github.com> |
||
|
|
dc07ca8872 |
feat(transactions): steer private marking in locked periods to ignore, with v1 and MCP ignore verbs (#1661) (#2031)
Decision (option a): a private marking stays a real booking (eget uttag/insattning), so it remains blocked in a locked or closed period; the legal escape for rows that are not affarshandelser is ignore. Private + locked now returns TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED with remediation naming the ignore paths instead of a bare PERIOD_LOCKED, on all four categorize surfaces and the bulk driver. New v1 POST/DELETE /transactions/{id}/ignore (isTransactionBooked-based 409, idempotent) and a staged MCP gnubok_ignore_transaction (+ accounted_ alias, search visibility to respect the tools/list payload ceiling) with operation_type ignore_transaction; the CHECK pair 20260831070000/070001 rebuilds the constraint from main's newest list plus the new value. Dashboard toast gains an Ignorera i stallet action. Closes #1661
|
||
|
|
516e8b62ff |
feat(inbox): match non-invoice documents via prominent amounts (#2048)
* feat(inbox): match non-invoice documents via prominent amounts Bankintyg, bank agreements and other documentKind "other" PDFs carry no invoice-style total, so extraction correctly left totals.total null and the document became structurally unmatchable: findUnderlagCandidates hard-drops items without a comparable amount and the picker lost the 40% amount signal. - extraction: new prominentAmounts[] field (amount + document's own label), populated only when totals.total is null; account/org/phone/reference numbers and zero amounts excluded. totals.total semantics untouched. - matching: bestProminentAmountVariance() tries each printed amount and feeds calculateMatchConfidence at reduced weight (0.3 vs 0.4) in both the agent candidate scorer and TransactionMatchPicker. - UI: inbox rail shows the detected amounts read-only for such documents, list falls back to a single distinct prominent amount, and extraction no longer reads as "found nothing". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(inbox): discount prominent-amount fallback instead of reweighting it Skeptic pass refutations on the first commit: normalized weighting made a reduced amount weight self-defeating. Date + exact fallback amount with no merchant scored (0.25+0.3)/0.55 = 1.0 ("100% sakerhet" on a wrong same-day transaction), and a DISAGREEING fallback amount scored above a disagreeing invoice total (0.67 vs 0.60) because shrinking the weight also shrank the penalty. - score fallbacks at full amount weight, then multiply by a flat FALLBACK_CONFIDENCE_FACTOR (0.85): agreement caps below certainty, disagreement stays at least as damning as for a real total. - agent candidate surface additionally requires the document date within DATE_TOLERANCE_DAYS, so an avtal listing 349 kr no longer matches every future 349 kr charge from the same counterparty. - bestProminentAmountVariance returns which amount matched + its document label, and the match reason names it ("Exakt belopp i dokumentet: 2 500 SEK (Engangspris)"): no more bare "Exakt belopp" reaching the agent while total_amount is null. - prompt: prominentAmounts restricted to non-invoice documentKinds, and never a parking spot for an unreadable invoice total. - fix the stale "deliberately the same list" comment on EXTRACTED_FIELD_ACCESSORS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(receipt-hunt): never propose on the prominent-amounts fallback Second skeptic pass: the nightly hunt is a third consumer of scoreUnderlagCandidates and inherited the fallback unaware. A bankintyg whose printed "Insatt belopp" equals a same-day outflow scores 0.85, which clears CERTAIN_CONFIDENCE (0.8) and skips LLM adjudication, on a pairing wrong by construction (the hunt scans outflows only; "Insatt belopp" labels an inflow), with document_amount null in the approval preview. UnderlagCandidate now carries amountSource ('total' | 'prominent') and selectProposals drops fallback-scored candidates. Non-invoice documents stay reachable through the manual picker and the agent candidate surface, both of which have a human reading the amounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(inbox): round fallback confidence via roundOre, not the naive pattern The two confidence discounts (and their test) tripped the naive-ore-round antipattern ratchet (625 vs baseline 622); use the sanctioned helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f43a6653f1 |
feat(salary): update_salary_run MCP tool and editable draft payment date (#2041)
* feat(salary): update_salary_run MCP tool and editable draft payment date payment_date drives the booking entry date but was only editable via the v1 PATCH. Close the gap on both remaining surfaces: - New staged MCP write tool gnubok_update_salary_run (search-only catalog; tools/list budget is at zero headroom) accepting the exact v1 PATCH field set: payment_date, voucher_series, notes. Draft-only with the same optimistic lock semantics, via a new shared service lib/salary/update-run.ts used by both the staging preflight and the commit executor. - Run header UI: payment date on a draft run is now an inline date input (prefilled, committed on blur/Enter, snaps back on failure), saved through the existing internal PATCH. Read-only once not draft. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): op-type migration, calc invalidation on date change, scanner compliance Consolidated CI + review fix pass for #2041: - pg-real: add 'update_salary_run' to pending_operations_operation_type_check (wholesale re-create, NOT VALID + VALIDATE pair, mirroring 20260828160000/1). - Swedish accounting review: a payment_date change on a draft run now clears every roster row's calculation_breakdown (shared service and internal PATCH alike), so both book preflights refuse the run until a recalculation has run against the new date; skatteavdrag and the AGI redovisningsperiod follow the payment month. Staging preview exposes invalidates_calculation and the next hint states the clearing. - no-phantom-columns: literal select strings in update-run.ts; ceiling +1 with a documented reason for the inherent patch-shaped UPDATE payload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): close skeptic findings on payment_date editing Skeptic round 1 refuted two paths; both closed: - Retry idempotency (correctness): the calculation_breakdown clear was gated on new-date-differs-from-stored, so a retry after a partial failure (header committed, clear failed) compared against the already updated date and skipped the clear forever, leaving a stale calculation bookable. The clear is now gated on payment_date being SUPPLIED, on all three surfaces (shared service, internal PATCH, v1 PATCH: the v1 route previously had no clear at all and bypassed the invariant). - Kontantprincipen (compliance): AGI derives its redovisningsperiod from period_year/period_month while the verifikat books on payment_date, so a cross-month payment_date change could book salary in one month and declare it in another. All three edit surfaces now refuse a payment_date outside the run's period month with the new structured error SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD; the UI date input is min/max-bounded to the period month. - The internal PATCH update is now optimistic-locked on status='draft' (races return 400 instead of silently writing), matching the v1 PATCH and the shared service, and the clear cannot fire for a run that left draft. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): carry book_skattekonto op types through the constraint re-create The sibling migration 20260830130000 (merged from main) re-created pending_operations_operation_type_check with book_skattekonto_row and book_skattekonto_rows. This branch's 20260830150000 sorts after it and re-creates the constraint wholesale, so its list must be that migration's superset or the two values would be silently revoked at apply time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): value-validate internal PATCH and grandfather out-of-period dates Two skeptic follow-ups: - The internal PATCH now validates values, not just keys: JSON body must be an object, payment_date must be ISO (shared ISO_DATE_RE), voucher_series a single A-Z letter, notes a string of max 2000 chars or null: the same rules as the v1 UpdateSalaryRunSchema, so nothing unvalidated can reach the DB through the whitelist. - Creation does not (yet) couple payment_date to the period month, so a legally created out-of-period date must stay correctable. All three edit surfaces now allow day adjustments within the run's CURRENT payment month as well as the period month (grandfather clause); no move can introduce a new wrong month. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): resolve migration version collision with delete_draft_invoice Main's delete_draft_invoice PR landed on the same 20260830150000/150001 versions and also re-creates pending_operations_operation_type_check. Rename this branch's pair to 20260830160000/160001 (applies last) and carry delete_draft_invoice through the wholesale re-create so nothing is silently revoked. Final list = sibling's list + update_salary_run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * docs(salary): regenerate accounted-api skill for the new PATCH pitfalls apiskill:check byte-compares the generated skill against the registry; the two pitfalls added to the v1 salary-runs PATCH endpoint made references/salary-runs.md stale and failed Core Build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9f8fa1b692 |
feat(invoices): draft invoice delete on v1 and MCP with staged approval (#2036)
* feat(invoices): draft invoice delete on v1 and MCP with staged approval
Draft customer-invoice deletion was web-only. This makes the same
semantics available on the v1 API-key surface and as an MCP write tool:
unnumbered drafts are hard deleted (no F-series number was consumed, so
no gap arises), numbered drafts are makulerade (status 'cancelled',
number retained so the F-series stays gap-free per ML 17 kap 24 and
BFNAR 2013:2). Non-drafts are refused; posted invoices can only be
reversed via a credit note.
- extract the web DELETE logic into lib/invoices/delete-draft-invoice.ts
with an explicit userId param (service-role clients null auth.uid());
the cookie route behavior is unchanged
- add DELETE /api/v1/companies/{companyId}/invoices/{id}: 409
INVOICE_DELETE_NOT_DRAFT for non-drafts (status override; the cookie
route keeps its 400), 404 generic NOT_FOUND, dry-run preview of the
outcome, mandatory Idempotency-Key; scope invoices:write
- fix the stale v1 PATCH pitfall that claimed a DELETE handler existed
- new MCP tool gnubok_delete_draft_invoice: staged operation requiring
approval, risk 'high' (both outcomes irreversible, never
auto-committed), catalogVisibility 'search' (tools/list budget at zero
headroom)
- delete_draft_invoice commit executor delegating to the shared service,
plus pending_operations CHECK constraint migration pair
(20260830100000/100001), risk tier, scope map, Granskning vocabulary
and sv/en labels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
* fix(migrations): renumber delete_draft_invoice pair after 20260830101500 on main
Merging origin/main brought 20260830101500_seed_agent_atom_bodies; the
constraint pair must sort after every version already on main so it
never applies out of order at merge time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
* docs(api-skill): regenerate accounted-api skill for the new invoices.delete endpoint
apiskill:check failed on CI: registering DELETE /invoices/{id} makes the
generated skills/accounted-api docs stale. Output of npm run
apiskill:generate, no hand edits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
* fix(invoices): pin staged delete outcome and align v1 risk metadata
Skeptic findings on PR #2036:
- Outcome pin: gnubok_delete_draft_invoice stages
expected_invoice_number alongside invoice_id; the executor passes it to
deleteDraftInvoice, which refuses with INVOICE_CANCEL_RACE when the
draft's number changed since staging. An unnumbered draft finalized
between staging and approval is now auto-rejected with a message naming
the new number, instead of silently switching from the approved hard
delete to a makulering. Ops staged without the pin keep legacy
semantics; single-phase callers (web, v1) are unaffected.
- v1 invoices.delete registerEndpoint risk raised medium -> high to match
the delete_draft_invoice pending-op tier (both outcomes irreversible);
generated accounted-api docs regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
* fix(migrations): renumber delete_draft_invoice pair after skattekonto collision
Merging origin/main brought PR #2039's 20260830130000/130001 pair, which
collides with this branch's versions AND re-creates the same
pending_operations CHECK wholesale. Renumber to 20260830150000/150001 and
rebuild the value list as a strict superset (skattekonto list plus
delete_draft_invoice) so applying last revokes nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
32cb8a22ce |
feat(mcp): Swedish keyword synonyms in tool search index (#2043)
gnubok_search_tools matched only English tool names and descriptions, so Swedish queries like "bankkonto" or "lönekörning" returned zero hits. Adds an optional keywords field on tool definitions, folded into the search matcher (name + description + keywords, case-insensitive, same ranking family as name matches) and seeds Swedish domain terms on 152 tools. Keywords are matcher-side only: they are never serialized into tools/list or search_tools output, since that payload budget has zero headroom. Guarded by new tests in search-tools.test.ts and lazy-auth.test.ts. Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
15df4c741c |
feat(mcp): supplier and date filters on list_supplier_invoices (#2035)
* feat(mcp): supplier and date filters on list_supplier_invoices Add supplier_id, supplier_name (case-insensitive substring, resolved server-side against the suppliers table), date_from and date_to (inclusive, on invoice_date) to gnubok_list_supplier_invoices, mirroring the v1 REST supplier-invoices filter shapes (eq on supplier_id, gte/lte on invoice_date). Unknown supplier_name returns an empty result without touching the invoice table; malformed dates and non-uuid supplier_id fail loudly with pointer messages. Schema text is kept deliberately terse: the tools/list payload guard sits at near-zero headroom, so the date format hint lives once in the tool description and the redundant status enum prose was trimmed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 * fix(mcp): bound the supplier_name match set fed to the IN clause PR Agent review flagged the unbounded id list: a broad substring on a large supplier register could build an IN clause past PostgREST URL limits. Cap name matches at 200 (cap + 1 fetched so overflow is detected) and fail loudly with a refine hint instead of degrading. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 * fix(mcp): loud validation, literal name matching, truncation signal on list_supplier_invoices Skeptic review refuted three behaviors; all three fixed: 1. Silent truncation: date filters invite period reconciliation but the 50-row cap was unsignalled. The invoice query now uses count exact and returns total_count and has_more (same contract as list_invoices), with a unique-id order tiebreaker so identical calls return identical subsets. 2. Silent filter drop: non-string values for the new params (and blank supplier_name) fell through typeof guards and returned the UNFILTERED ledger presented as filtered, the same class arg-guard exists for. They now throw clear errors. Dates are also calendar-validated, so 2026-02-30 fails loudly instead of as a raw Postgres cast error. 3. Wildcard broadening: PostgREST rewrites * in ilike values to %, so Star*Mart matched Starke Martinsson AB. supplier_name is now matched as a literal case-insensitive substring in JS over the company's suppliers (fetchAllRows, parity with list_suppliers), cap unchanged. Two redundant property descriptions dropped to fund the outputSchema additions under the tools/list payload ceiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8f68421fef |
feat(skatteverket): expose skattekonto row booking via MCP staged operations (#2039)
* feat(skatteverket): expose skattekonto row booking via MCP staged operations Skattekonto READ and RECONCILE were already MCP tools, but BOOKING rows existed only behind the cookie-session extension routes. This closes the flow on API-key surfaces: - New staged MCP write tools gnubok_book_skattekonto_row and gnubok_book_skattekonto_rows (batch, 1-200 ids), catalogVisibility 'search', STAGED_OPERATION_SCHEMA, stage-time bookability gates and rule-matched counter-account preview. Staging never books. - New pending-operation types book_skattekonto_row / book_skattekonto_rows (CHECK constraint migration pair, risk tier medium, sv/en labels). - Commit executor reaches the skatteverket extension through the registry-resolved services channel (core never imports @/extensions): new commitBookSkattekontoRows service wraps the SAME bokforSkattekontoTransactionsBatch helper the HTTP bokfor-batch route uses (draft + commit per row via the bookkeeping engine, requireSettled), with the approving user id passed explicitly (auth.uid() is NULL on the service client). No booking math or account mapping changed: auth-surface exposure only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * chore(migrations): move book_skattekonto constraint pair after main's newest version origin/main gained 20260830101500 while this branch was in flight; keep the new pending_operations constraint migrations sorted after it so they apply in order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(skatteverket): resolve CI and review findings for skattekonto booking tools - Inline the skattekonto_transactions select strings at both stage-time call sites so the no-phantom-columns scanner can resolve them (the shared const pushed the unresolvable-expression count past the ceiling and failed Unit tests 3/4). - Return a fixed public message from the commitBookSkattekontoRows batch-level catch instead of the raw exception text; the raw error stays in the server log (Superagent P2). - Add verifikat_description to the staged previews so the reviewer sees the exact ledger text the booking helper writes, Skatteverket motpart included (Swedish accounting review). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d39a9719a3 |
fix(peppol): say Peppol send is gated per company, never absent (#546) (#2021)
Peppol sending has been live since #1780 behind a per-company access grant, but the MCP skills, the swedish-invoice-compliance atom, docs/PEPPOL_FOUNDATION.md and the v1 :send / :mark-sent descriptions still told agents it did not exist. Every text now says gated per company (requested under Installningar > Fakturering) and keeps the restrictions explicit: aktiebolag senders, standard invoices only, Swedish org-number buyers, no MCP or v1 Peppol send verb yet, :mark-sent as the recovery step when a network-accepted send fails issuance. The skills guard test pins the truthful claim across all surfaces. Includes the regenerated agent_atom_registry seeds and skills/accounted-api references. Refs #546 |
||
|
|
d11d0a2e90 |
feat(reconciliation): match one bank event to several verifikationer (1:N) (#1553) (#2029)
One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553 |
||
|
|
749f90fe62 |
feat(inbox): direct-to-storage upload for files over the hosted body limit (#1551) (#2030)
Hosted uploads larger than the 4 MB multipart ceiling (Vercel's 4.5 MB request-body cap) now go POST /upload/create (signed PUT URL, rate-limited) -> PUT to the raw Storage URL -> POST /upload/complete (server-side magic-byte and size validation, sha256, WORM move, idempotent), reusing the #1378 pending-upload primitives. uploadAndExtract is split into uploadDocument + processArchivedDocument so both paths share the inbox pipeline. Dokumentinkorgen and the supplier-invoice form use the new path only above the threshold; files that fit keep the multipart route. Cap stays at 10 MB (the issue asks for 20 MB: founder call). Refs #1551 |
||
|
|
523fba0419 |
feat(email): SMTP mailer behind the EmailService seam (EMAIL_PROVIDER=smtp); Resend stays the hosted default (#1746)
SmtpEmailService (nodemailer 9.0.5, exact-pinned) behind the existing EmailService seam. Provider resolution: EMAIL_PROVIDER wins, else RESEND_API_KEY selects Resend (hosted byte-identical), else SMTP_HOST selects SMTP. From header is built exactly like the Resend service after #1956 (no 'via <app>', fromAddress honored, platform-sender retry). STARTTLS is required by default (requireTLS) with SMTP_REQUIRE_TLS=false as an explicit opt-out for a plaintext LAN relay. Docs, env examples and the generated extension registry updated. |
||
|
|
521f437072 |
feat(migration): link migrated invoices to their registration voucher (#1463) (#2024)
Visma and Fortnox migrations now carry each invoice's source voucher reference, and after the invoice steps a core linker resolves it against the SIE-imported ledger (voucher-ref resolver by date, corroborated by the 244x credit / 151x debit amount, posted only, unreferenced only) and writes registration_journal_entry_id / journal_entry_id. Anything ambiguous, mismatched or unresolved is reported and left NULL; journal entries are never written. The arcim-migration /reconcile endpoint can relink already-migrated companies. Payment vouchers are PR B. Refs #1463 |
||
|
|
4b7343d5ec |
fix(errors): keep the SQLSTATE when wrapping database errors (#2027)
isTransientFailure() checks the driver's error code first, and 57014
(statement timeout) is already in its transient set. But the wrapping idiom
across the codebase was `throw new Error(\`Database error: ${err.message}\`)`,
which keeps the prose and drops the code. A retryable timeout therefore
arrived anonymous and resolved to UNKNOWN_ERROR: "Något gick fel. Försök
igen." An agent cannot dispatch on that, so it retried.
On production over 60 days, with the two bot integrations excluded: 1024 real
agent failures, 645 of them UNKNOWN_ERROR across 60 actors and 57 companies.
82 retry streaks of three or more identical failures, 462 wasted repeat calls,
53.1% of all agent error calls sitting inside a streak.
The worst offender traces to one line in core. gnubok_query_journal failed 164
times at a p50 of 8110ms while every other failing tool sat between 1 and
315ms, and its path is fetchEntryLines -> fetchAllRows, where
lib/supabase/fetch-all.ts threw `new Error(error.message)`. That is the
highest-traffic strip point in the repo: 31 callers, every paginated read.
query_journal already had a correct TRANSIENT_ERROR branch offering "retry, or
narrow with date_from/date_to" which could never fire, because by the time it
looked, the code was gone.
fetch-all keeps the driver message verbatim: callers match on the existing
text, and this adds the code rather than rewording anything.
Attaching the code is safe. extractCode() only accepts /^[A-Z_]+$/ and every
SQLSTATE contains digits, so it cannot be mistaken for one of our own stable
codes. There is a test for that, and one asserting the old bare-Error shape
still resolves to UNKNOWN_ERROR so the fix cannot silently regress.
Also stops rendering the literal "undefined" when a driver-level failure
carries no message, which is the string that made these unsearchable.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a1cafe495f |
fix(invoice-inbox): read whole PDFs (last-page slice + truncation retry) (#2014)
* fix(invoice-inbox): read whole PDFs (last-page slice + truncation retry) PDF extraction read only part of well-structured PDFs, two confirmed mechanisms (21-day prod window: 49 sliced docs, 29 silent empties): - The auto-extract page budget was 3 (Bedrock-latency legacy, issue #553) and the slice kept only the first pages, so multi-page invoices lost the final page where totals, OCR and 'Att betala' sit. The budget is now 8 on pdf-native backends (Claude reads PDFs directly); the slice always keeps the last page. Rasterizing self-host backends keep the old budget of 3. - A max_tokens-truncated model answer was parsed as-is, failed, and became an all-null extraction with no trace. extractFromDocument now reports stop_reason max_tokens / finish_reason length as truncated; the extractor retries once at double AI_EXTRACTION_MAX_TOKENS and logs ai_extraction_truncated either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xosyW53HUa9JoFiDayhSk * fix(invoice-inbox): sweep cutoff covers the slower two-call extraction Skeptic finding on #2014: the crash-recovery sweep flipped 'processing' rows to an empty skeleton after 2 minutes, but a deferred extraction can now legitimately run 3-5 minutes (8 native pages plus one truncation retry at a doubled token cap), so the sweep stole the row and the CAS discarded the worker's real result. Cutoff raised to 10 minutes. Also: pages_partial_note made period-agnostic (old rows were extracted from first-pages-only slices, so naming the last page was retroactively wrong for them), and two stale first-pages-only comments updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xosyW53HUa9JoFiDayhSk * fix(invoice-inbox): keep the first extraction response when the retry throws CodeRabbit finding on #2014: a throttled/failed retry call bubbled to the outer catch before rawText was assigned, discarding a first response whose text may parse fine despite the truncation flag. The retry is now caught locally (logged as ai_extraction_retry_failed) and the first result flows on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xosyW53HUa9JoFiDayhSk --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e8aa0670ca |
feat(salary): agent path to set this month's per-run salary (#2015)
* feat(salary): agent path to set this month's per-run salary
Agents could not do variable owner pay: the only per-run edit tool,
gnubok_update_payslip_line, edits the display-only Grundlon line that
every recalculation rebuilds from salary_run_employees.monthly_salary,
so the fixed employee salary silently won (user-reported).
- lib/salary/run-employees.ts: setRunEmployeeSalary() shared service
(draft gate, roundOre, 0 = nollkorning, display-line refresh); the
cookie route PATCH now delegates to it (behavior unchanged)
- MCP: gnubok_set_run_salary staged tool (search catalog: tools/list
budget at zero headroom), op type set_run_salary (medium risk),
commitSetRunSalary executor, payroll:write scope, payroll_month
loadout + payroll-monthly skill step; update_payslip_line description
now warns that recalc rebuilds base salary lines
- v1 REST: PATCH /salary-runs/{id}/employees/{employeeId} accepting
monthly_salary (draft only, dry-run, idempotency key)
- Migration pair (NOT VALID + VALIDATE) adds set_run_salary to the
pending_operations op-type CHECK; base list verified against prod live
- Tests: service, staged tool, executor, cookie route, v1 route; spec
snapshot updated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG
* fix(salary): harden set_run_salary per skeptic + CI findings
- Clear calculation_breakdown when the per-run salary changes so the
existing book preflights force a recalculation: a run can no longer
be booked with gross/tax derived from the old salary (skeptic R1)
- Enforce SALARY_OVERRIDE_MAX (10 MSEK) in the shared service and the
v1 body schema: closes the unbounded/1e307-overflow path that wrote
Infinity -> NULL -> 500 (skeptic R2)
- Promote gnubok_set_run_salary to the default catalog: a search-only
WRITE is uncallable on Claude.ai (update_customer lesson) while three
surfaces pointed agents at it; payload ceiling bumped 63.8K -> 64.4K
with a ledger entry, read-demotion left as its own change (skeptic R3)
- Granskning label type_set_run_salary in vocabulary.ts + sv/en (R4)
- Display-line refresh is fire-and-forget again (write already
committed; matches pre-refactor route behavior) and DB error details
carry the SQLSTATE code for Swedish error mapping
- v1 risk metadata aligned to 'medium'; NOT_DRAFT message now covers
salary edits, not just roster changes
- npm run apiskill:generate committed (CI apiskill:check failure)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG
* chore(migrations): rename set_run_salary pair past main's newest versions
origin/main gained 20260828120000 and 20260828154800 after this branch
staged 20260828110000/1; out-of-order versions are skipped at merge, so
the pair moves to 20260828160000/1 (byte-identical SQL, reference in the
VALIDATE header updated).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG
* chore: retrigger Supabase preview after migration-version repair
The preview branch tracked 20260828110000/1 before the rename to
20260828160000/1; the orphan rows are deleted from the preview branch's
schema_migrations (preview only, prod never saw those versions) and this
empty commit re-runs the tasks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
6f0f4d1eea |
fix(mcp): flow-order signals in tool results: ask the bank, import history first (#1955)
E2E #5: the agent skipped the bank question (generic picker link) and went straight to connect cards for a company whose fiscal year started 13 months ago (history import should come first). The skill says both, but the agent follows tool results more reliably than skill prose, so the rules now live in the results themselves: - gnubok_lookup_company still_to_ask gains two flow questions: which bank (for the bank= deep link) and whether the bookkeeping lived in a previous system (SIE import BEFORE bank). - gnubok_create_company's confirmed result computes days of history in the fiscal period: past 90 days it emits history_note (PSD2 cannot reach it; run gnubok_sie_preflight first) and reorders the remaining- setup message to (1) SIE import, (2) bank, (3) Skatteverket. - gnubok_connect_bank called without bank= now opens its instructions with a nudge to ask for the bank and re-call for the direct consent link. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cd9fb5b717 |
feat(mcp): byte-exact SIE upload path + brevity and memory-first onboarding (#1954)
From the fourth E2E run: the agent correctly refused to reproduce a 104 KB SIE file token by token (silent mid-verifikat truncation) and dead-ended to the web wizard, and its replies were walls of compliance prose. 1. gnubok_create_sie_upload: signed same-origin upload URL (reuses the pending-document infra; .se/.sie/.si only, 50 MB HTTP cap). gnubok_sie_preflight and gnubok_import_sie accept upload_id as the byte-exact source, plus optional sha256 (hex of the raw bytes) verified on the upload_id/base64 paths so truncation is DETECTED, never silent. Inline content above 120k chars is refused with a pointer to the upload flow. Scope bookkeeping:write (same intent as import_sie). 2. Skill: brevity rule (max ~8 short lines per reply, one warning per step, no legal essays), memory-first rule (check what is already known before asking the opening questions), the upload-first SIE step, and gnubok_explain_voucher_gap after import for skipped voucher numbers. 3. CONNECTORS.md starter prompt rewritten memory-first so it stays copy-paste ready without the user's own data in it. Plugin v1.2.2. tools/list ceiling 62K to 62.4K documented in the bench. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4af7469523 |
feat(onboarding): minimal input: orgnr + moms period is the whole ask (#1952)
* feat(onboarding): minimal input: orgnr + moms period is the whole ask
Two fixes from the third E2E attempt (2026-08-26):
1. accounting_method is now optional in CompanySetupSchema and defaults by
form in planCompanySetup: aktiebolag = accrual (the norm), enskild
firma = cash (the common small-EF choice; legal under 3 MSEK, BFL 4
kap 4 paragraf). The plan flags the default (resolved.accountingMethodDefaulted)
and gnubok_create_company's preview carries accounting_method_defaulted
so the readback names it and the user overrides in the same 'ja'.
Never silent: the preview is the checkpoint. Applies to the MCP tool
and POST /api/v1/companies (additive; response shows the resolved
value). The lookup tool's still_to_ask no longer lists it.
2. The agent refused a real orgnr because the user said 'nytt bolag' and
the registry showed an established company ('Stopp. Numret matchar
inte ett nytt bolag'): lookup instructions now state that an
established company with F-skatt/VAT is the NORMAL case (new = new to
Accounted) and the orgnr is never second-guessed for looking
established.
Skill + plugin (v1.2.1) updated; API skill regenerated; DECISIONS.md entry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(onboarding): surface the kontantmetod 3-MSEK condition on the defaulted cash method
Compliance-review finding on #1952: the EF cash default carries a legal
eligibility condition (turnover normally under 3 MSEK, BFL 4 kap 4 §)
that a client not reading the onboarding skill would never see. The
create preview now carries accounting_method_note with the condition
whenever cash was defaulted, and the v1 pitfall states it for API
integrators. The registry cannot verify turnover, so the confirm-time
human check is the gate; the default itself stays (a brand-new EF has
zero turnover by definition).
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>
|
||
|
|
3ee3565d6d |
perf(reference-data): sweep the remaining raw reads onto the session cache, ratchet to 0 (#1941)
Final consumer migration of the responsiveness plan: the 35 files still fetching fiscal periods, settings, accounts, cash accounts, dimensions or templates on their own now read lib/reference-data, and every client write site invalidates the shared cache instead of refetching locally. Settings and registries: FiscalYearsManager, FiscalPeriodEditor (period snapshotted once per company so a revalidation cannot reset dates being edited), BookingTemplatesPanel, ChartOfAccounts, ChartOfAccountsManager, EditAccountDialog, CorrectionEntryDialog, StrikeLinesDialog, InvoicePaymentAccountsSettings; the dimensions registry (DimensionsManager, DimensionCombobox, LineDimensionFields, DimensionFilter, bookkeeping/[id]) reads useDimensions and the ad-hoc fetchDimensions/fetchDimensionsCached helpers are deleted. Pages and pickers: CashAccountSelector (FyPicker-shaped restore, once per company load), use-account-names, FiscalYearGapNotice, OpeningBalancePeriodStep, BankFileConfirmStep, ImportReviewStep, the import page (invalidates accounts + periods after a SIE execute), customers list, invoices list + detail, pending, salary employee, asset dispose, year-end and periodisering pages (invalidate periods after closing), reports DimensionPnlView (its pivot picker read the wrong payload key and was always empty; it now populates), SkatteverketPanel, TemplatePicker, ArticleForm (vat_registered). Invoice dialogs and extensions: SendInvoiceDialog, PaymentBookingDialog (init reduced to the credit-note lookup + catalogue, proposal and voucher preview fire on open when cached; a local getSession replaces the network getUser for the fallback CC), InvoiceInboxWorkspace, TicWorkspace, ArcimMigrationWorkspace (invalidates after each SIE import step), enable-banking AccountPickerDialog. raw-reference-fetch ratchet: 35 -> 0 files. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ec9cab24cc |
feat(mcp): ask the bank first: connect link deep-starts the named bank's consent (#1951)
* feat(mcp): ask the bank first: connect link deep-starts the named bank's consent The connect card used to open the generic picker page; the user then chose the bank there. The agent now asks 'vilken bank har företaget?' among the opening questions and passes it to gnubok_connect_bank, whose connect_url becomes /import?mode=psd2&bank=<name>. BankSelector resolves the name (exact, then unique prefix, then unique substring: ambiguous names fall back to the prefilled picker rather than guessing an institution) and auto-starts that bank's consent through the same onConnect handler, so the duplicate-pending and renew-instead guards stay fully interactive. The param is stripped via history.replaceState after the one-shot so an aborted bank flow plus back-navigation does not silently relaunch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): first-year suggestion keeps the AB vs enskild firma distinction Compliance-review finding on #1949: the suggestion text collapsed both forms onto a 31 December end. Only an enskild firma's first year MUST end 31 December; an AB may pick any end within BFL 3 kap 3 §'s 18-month cap, with 31 December as the common default. The lookup tool's still_to_ask line and the skill now say so explicitly, and first-year-defaults documents that fiscalYear-null is a strong-not-perfect filed-report signal that must only ever feed confirm-question suggestions. 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> |
||
|
|
b436d47b64 |
feat(mcp): onboarding flow v2: migration-first SIE import in chat, one-confirm momentum, extended first-year heuristic (#1949)
Three changes from the first real E2E run (Arcim, 2026-08-26): 1. gnubok_sie_preflight: read-only scan of a SIE file shared in chat BEFORE anything is staged: parse, validate (per-verifikat balance, IB, closed-year P&L residual), CP1252-mojibake tripwire, duplicate file/period check, org-number match against the company (the wrong-company import is the worst silent failure this flow can have), and suggested account mappings shaped for direct passthrough to gnubok_import_sie. Both tools now also accept file_content_base64, decoded with the same encoding detection as the HTTP upload route so CP437 exports keep their åäö. 2. Onboarding skill v2: opens with TWO questions (orgnr + 'vilket system hade du innan?'), imports history before the bank (PSD2 rarely reaches far enough back), and a momentum rule: the create preview is the ONLY stop; connect tools are called without asking, and categorization starts as soon as the bank is active. connect_bank instructions now describe the account-selection dialog that actually gates the first sync, and the bank history cap. 3. deriveFirstYearDefaults: no closed fiscal period in the registry now extends the first-year window from 12 to 18 months (BFL 3 kap 3 §): a 13-month-old company with no annual report is still in its first, extended räkenskapsår (the Arcim case the 12-month rule missed). Applied in the web journey and the lookup tool. tools/list ceiling 61.5K to 62K, documented in the bench. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
188816652d |
docs(api,mcp): tool counts, changelog backfill, version-header honesty, lazy auth, endpoint map (#1929)
Brings the developer-facing API and MCP docs back in line with origin/main (audit 2026-08-26). Docs only; no runtime behaviour changes. - Tool counts: the server registers 153 tools; docs said 90+/100+/120. All now say "150+" (connect-claude, gnubok-mcp README, plugin README, mcp-server rules, CLAUDE.md, registry entry with refreshed updatedAt). Not derived from the tools array: lib/ must not import @/extensions/. - REST changelog: backfilled the additive 2026-08 changes (#1909 report date ranges + PDFs, #1864 POST /companies, #1773 vat-declarations, #1405 PATCH settings, #1724/#1788 customer personal_number, #1809 cash_account_id filter). API version date unchanged. - Version headers: Gnubok-Deprecation is planned, not emitted; the Gnubok-Version request header is not read today (version.ts comment, versioning page, conventions overlay, regenerated skills/accounted-api). - connect-claude Path A documents lazy auth (connector works before an account exists; sign-in on the first company-scoped call). - MCP server README: real Anthropic SDK call sites, real resource URIs, pending-operations widget, public-tools/tasks/origin-guard/pii-guard. Rules file gains Lazy auth + feedback/tasks paragraphs. - api-routes endpoint map regenerated from the filesystem (560 routes, 55 families incl. v1, agent, reconciliation account-keyed, dimensions, peppol, rot-rut, webshop-orders, mileage, billing, skatteverket, receipt-hunt). - gnubok-mcp/accounted-mcp: /settings?tab=api is the pre-redesign URL; now /settings/api (README + help hints, no version bump). Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f338850bd0 |
fix: hide API-archived customers and suppliers from lists and pickers (#1927)
* fix: hide API-archived customers and suppliers from lists and pickers The v1 API soft-archives customers and suppliers (archived_at, plus is_active=false on suppliers) and its own list routes hide those rows behind ?include_archived=true. No other surface filtered archived_at, so an archived counterparty stayed a normal row in the dashboard rosters, the internal /api/customers and /api/suppliers list routes, the MCP list tools and every customer/supplier picker. Apply the same canonical `archived_at IS NULL` filter on every non-v1 list and picker path: - /api/customers GET, /api/suppliers GET (feeds the customers page and the supplier-invoice form) - suppliers dashboard page (reads suppliers via browser Supabase) - InvoiceEditor and NewRecurringScheduleDialog customer pickers; an invoice or schedule being edited keeps its current customer visible (archiving does not refuse on drafts, so a draft can point at one) - deadlines page and CalendarWorkspace customer pickers - InvoicePreviewCard sample customer - gnubok_list_customers and gnubok_list_suppliers: hidden by default, optional include_archived boolean mirroring the v1 flag; rows now carry archived_at so an agent can tell them apart when opted in Detail routes and by-id lookups are untouched: an archived row still opens. The delete-vs-archive semantics are unchanged. The tools/list payload guard moves 60.7K to 60.8K: main had ~6 tokens of headroom, so even the bare boolean contract crossed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(schema): raise the unresolvable-expression ceiling by 2 for the archived-customer picker filters The two .or('archived_at.is.null,id.eq.<uuid>') filters keep an edited draft's archived customer selectable. The uuid is a runtime value, so the scanner cannot resolve the expression; both columns exist and the filter is covered by the archived-counterparty tests. 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> |