Commit Graph

13 Commits

Author SHA1 Message Date
Mattsson c187fabf92 feat(shopify): Shopify order/refund feed into the transactions inbox (#1474)
* feat(shopify): Shopify order/refund feed into the transactions inbox

New extensions/general/shopify feed extension, modeled on the WooCommerce
feed: connect a Shopify store with Dev Dashboard custom-app client
credentials (client credentials grant, ~24h tokens, never stored), then a
nightly cron + manual sync imports paid orders and refunds via the GraphQL
Admin API (pinned 2026-07) into the transactions inbox on clearing account
1584. Feed-only: nothing auto-books. Zero PII fields are queried, keeping
the app outside Shopify's protected customer data program.

- shopify_connections migration (RLS, revoke-never-delete, encrypted
  client id/secret) + shopify_sync capability and bank_sync-mirrored
  backfill
- frozen external_id scheme shopify_{shop_domain}_order|refund_{id},
  scoped on the shop domain so reconnects never re-import
- cursor sync on updated_at windows with 24h overlap, lock-date drop at
  map time, ingest-failure cursor floor, deadline stop-and-resume,
  revoked-credential flip
- /import card + settings panel, sv/en i18n, cron 03:15 in vercel.json +
  regenerated Docker crontabs, logo, events, panel registry
- 65 unit tests + pg-real RLS test; extensions.schema.json enum also
  gains the missing stripe entry (pre-existing drift)

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

* fix(shopify): review findings from PR 1474

- token exchange: a 429 that survives every retry is throttling, not a
  credential failure; stop remapping retryable 4xx to 401 so sustained
  throttling can no longer flip the connection to revoked and delete the
  stored credentials (CodeRabbit critical)
- order sync: advance a scanned-through watermark (run start, capped by
  the failure floor) after a fully-listed window, so empty first runs and
  quiet stores rotate to the back of the cron's oldest-first selection
  instead of permanently occupying the 50-connection batch (CodeRabbit
  major, starvation)
- add handler-level tests for the orders cron route (auth 401, disabled
  503, unconfigured no-op, query failure, capability skip, happy path,
  per-connection failure isolation, revoked marking)
- add 401 tests for /sync, /transaction-sync and /disconnect; pin the
  cursor floor rule with a two-order page; stub the encryption key via
  vi.stubEnv
- note in the panel description (sv/en) that orders can mix VAT rates and
  must be split at booking (Swedish review advisory)
- DECISIONS.md: wrap underscore identifiers in backticks (MD037)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 12:44:08 +02:00
Mattsson 707d597b2e feat(woocommerce): store order/refund feed extension (#1442)
* feat(woocommerce): store order/refund feed extension

Connect a WooCommerce store via the wc-auth key handshake (manual key
fallback) with per-store consumer key/secret AES-256-GCM encrypted at rest,
and import paid orders and refunds into the transactions inbox as a
bank-style feed on the 1680 cash account. Feed-only: nothing auto-books,
gateway fees/payouts are out of scope (core wc/v3 does not expose them).

Sync is cursor-paginated on modified_after (offset pages only inside
same-second date_modified ties), terminates on an empty page, holds the
cursor below failed refund fetches / ingest errors / deadline-skipped work,
checks the time budget between refund fetches, and drops rows dated on or
before bookkeeping_locked_through on every run. Nightly cron gated on the
extension registry + new paid capability woocommerce_sync (backfilled to
existing bank_sync grant holders).

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

* fix(migrations): move woocommerce migrations past main's 20260806090000

origin/main gained 20260806090000_recurring_schedule_interval_months while
this branch was in flight; identical version timestamps abort the Supabase
apply, so the two new migrations move to 20260806170000/20260806170100.

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

* fix(woocommerce): resolve CodeRabbit review findings

- callback 503s early when WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is
  unset: encryptCredential would otherwise throw after the probe and
  strand the pending row without error_message
- disconnect and upstream-revoke clear the encrypted consumer key/secret:
  nothing reads them after revoke and keeping decryptable dead
  credentials is unnecessary retention
- manual sync gets a 240s time budget and the panel reports a truncated
  run as 'partial, sync again' instead of a normal completion
- listOrderRefunds terminates on an empty batch (hosts may cap per_page),
  dedupes by id against hosts that ignore page, and caps total pages
- unparseable money strings count as errors and log instead of being
  silently identical to a zero total
- pg test uses per-run unique store URLs so committed rows cannot hit
  the store_url partial unique index across pg-real runs

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

* fix(woocommerce): resolve CodeRabbit cycle-2 findings

- listOrderRefunds throws when the page cap is exhausted with data still
  flowing, instead of returning a silently partial list the sync cursor
  would advance past; the error routes into the existing held-cursor
  refund-retry path
- partial sync results keep the row-error count, and the partial toast
  string surfaces it (ICU plural, hidden at zero) in both locales

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

* chore: retrigger CI after dropped push event

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 23:30:00 +02:00
Mattsson 5d7952a01e feat(mcp): model-free document upload via signed URL (#1378)
* feat(mcp): model-free document upload via signed URL (#748)

Adds gnubok_create_document_upload + gnubok_complete_document_upload so
document bytes reach storage through a short-lived signed PUT URL and
never pass through the model context. Fixes silent base64 corruption on
real-size PDFs and the context blowup on batch uploads.

- pending/ staage keys with TTL cleanup; completion validates magic
  bytes + SHA-256, moves bytes to the WORM key and adopts the reserved
  UUID as document id, making retries and concurrent completions
  idempotent
- legacy gnubok_upload_document kept for clients without file access,
  description now points to the signed-URL pair; shared mime resolution
  and inbox-item creation extracted
- both new tools mapped in TOOL_SCOPE_MAP (transactions:write) and
  MCP_TOOL_CAPABILITY_MAP (ai) so the paywall and scope gates hold
- payload guard ceiling 58.5K to 59K after trimming the create tool's
  outputSchema to upload_id/upload_url/expires_at

Fixes #748

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

* fix(mcp): satisfy capability-map lock and phantom-column scanner

The exact-entries lock in capability-maps.test.ts now includes the
signed-URL pair as dispatch-only AI tools, and the inbox insert uses a
literal payload (explicit UUID instead of a conditional spread) so the
no-phantom-columns scanner can resolve every column.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:41:05 +02:00
Mattsson 0510d4c13f fix(sync): stop expired trials from starving automatic bank and skattekonto sync (#1376)
The bank sync and skattekonto sync crons fetched the 50 oldest
connection/token rows and only then checked entitlements per item, so
expired-trial rows permanently occupied every batch slot and entitled
companies were never synced automatically.

Fetch all candidate rows, resolve capability grants in bulk via the new
getCompanyIdsWithCapability() (company and firm grants cascade, expired
grants excluded, explicit per-company disable wins), and apply the
50-item run cap after filtering. Entitlement query failures now fail the
run instead of silently skipping every company.

Fixes #563

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 17:32:39 +02:00
Mattsson 98d0c7f2d0 Add/stripe skv (#1004)
* fix(salary): align pain.001 salary file with the Swedish domestic bank dialect

Verified against the Swedish Common Interpretation of ISO 20022
(Bankforeningen, Common Payment Types in Sweden, Appendix 1 Example 4:
Salaries) and Nordea Corporate Access pain.001 examples v2.6 (2026-06-22),
and XSD-validated against the official pain.001.001.03 schema:

- drop SvcLvl SEPA (SEPA credit transfers are EUR-only; omitting SvcLvl
  gets the domestic NURG default)
- drop RmtInf (not allowed for SALA salary payments; the beneficiary
  statement text comes from the Dataclearing LON code)
- address employees domestically: clearing as CdtrAgt ClrSysMmbId SESBA,
  account WITHOUT clearing as CdtrAcct Othr with SchmeNm BBAN
- share the clearing/account split (Swedbank 5-digit shift, Nordea
  personkonto prefix dedup) between the LB and pain.001 generators via
  splitDomesticBankAccount, fixing pain.001 duplicating the personkonto
  clearing
- clamp MsgId/PmtInfId/InstrId/EndToEndId to Max35Text with the per-tx
  counter surviving truncation; carry the org number on Dbtr
- return 400 from the pain001 route on an invalid clearing instead of
  emitting a broken file

Also includes two unrelated decision-log lines from the parallel
revisor-review session (DECISIONS.md is a shared append-only log).

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

* feat(nav): surface the year-end chain in the sidebar

Add Periodiseringar, Arsredovisning (aktiebolag only) and
Inkomstdeklaration (INK2 for AB, NE-bilaga for EF) to the Skatt &
bokslut group, in workflow order. Entity gating via a new entityOnly
flag on NavItem; isActive carve-outs extended so exactly one row
lights up for the new routes. Driven by an external revisor review
that concluded these features did not exist because none of them
were reachable from the nav.

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

* feat(stripe): Stripe Connect integration behind config gate

Connect OAuth per company (only the acct_ id is stored), automatic
single-use Payment Links on invoice send, deterministic payment
settlement against 1686 (BAS moved acquirer receivables 1580 -> 1686),
payout booking with reverse-charge fees (6570 + 4535/4598 + 2645/2614),
and a 15-minute sync cron. Non-deterministic events land as
needs_review, never guessed at.

Fully dark without STRIPE_CONNECT_CLIENT_ID: connect returns 503, the
send hook and cron no-op, and the settings page shows 'Kommer snart'
(hosted) until the Connect platform is verified. Self-hosted keeps the
honest not-configured message.

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

* fix(deadlines): add shared completeTaxDeadline and fix dead AGI deadline auto-complete

generate-declaration.ts has updated non-existent columns (type/period/
status) since inception, so the arbetsgivardeklaration deadline was
never auto-completed. Replace with a shared helper targeting the real
schema (tax_deadline_type/tax_period/is_completed), also used by the
kvittens crons and moms handlers in the follow-up commit.

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

* feat(rot-rut): import Skatteverket beslutsfil and record decisions on payout requests

Parse the beslutsfil JSON from Skatteverkets rot/rut e-tjanst and record
godkant belopp on the matching begaran: matched by stored
skv_referensnummer first, then exact name among active undecided
requests; arenden by fakturanummer then personnummer, exactly-one or the
beslut errors (all-or-nothing). Never auto-settles: recording the beslut
and booking the payout are separate acts. Exposed as an API route and
the gnubok_import_rot_rut_beslut MCP tool.

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

* feat(skatteverket): system auth for background reads, one-click VAT submit, kvittens notifications

Hybrid auth program: system CCG (org certificate) for background reads
while personal BankID stays for interactive submissions, since SKV
per-flow refresh tokens live 65 min and crons structurally cannot run
on them. All system-auth code sits behind SKATTEVERKET_SYSTEM_AUTH_MODE
(default off) with a stub transport until the Expisoft cert and CCG
avtal land; auth resolution is centralized in resolve-auth.ts.

Also in this change:
- One-click VAT submit chaining kontrollera -> utkast -> las
  server-side with a stage discriminator; step-by-step buttons demoted
  to the overflow menu.
- Kvittens crons (AGI + new VAT schedule) with email-only
  notifications, deduped in notification_log under the new
  skv_kvittens type.
- Ombud grant probe + verification UI in the connect panel, and a
  dashboard promo card for unconnected companies.
- skatteverket_company_connections table with pg-real coverage.

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

* feat(salary): auto-settle AGI tax payment from skattekonto and surface SKV reconnect on the tax card

The "Skatt att betala" card only cleared via the manual mark-paid button
on the run detail page; the promised automatic flip from the Skattekonto
sync was never implemented, so paid periods stayed red.

- settleAgiTaxPayments: during every skattekonto sync, a booked
  "Arbetsgivardeklaration YYYYMM" debit row settles the matching
  agi_declarations.tax_paid_at, but only when the amount equals the
  declared total to the ore and the account is not in deficit
  (deterministic; drift or deficit falls back to manual).
- Salary overview card: reconnect hint when the SKV token needs
  re-consent (link to /settings/tax, silent when the extension is off),
  plus an inline "Markera som betald" button reusing the existing
  endpoint and salary_payments strings.

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

* Add cloud backup scheduling and alerting features

- Implement unit tests for scheduling logic in `schedule.test.ts`, covering various scenarios for determining if a backup schedule is due.
- Create a new module `backup-alert.ts` to handle failure alerts for cloud backup auto-sync, including email notifications for reauthentication and repeated failures.
- Introduce `schedule.ts` to manage scheduling logic, including handling local time zones and converting between local and UTC hours.
- Add CSV report generation functions in `archive-csv.ts` for trial balance, income statement, balance sheet, and general ledger, ensuring compatibility with Swedish Excel formats.
- Create a README generator for the archive structure in `archive-readme.ts`, providing clear documentation for users accessing backup files.
- Implement tests for CSV report generation in `archive-csv.test.ts`, ensuring correct formatting and content.
- Establish a full-archive coverage contract test in `full-archive-coverage.pg.test.ts` to ensure all company-scoped tables are properly classified for backup.

* fix(stripe): correct invoice clearing reference and improve type safety in sync logic

* fix(invoices): narrow accountingMethod before resolveInvoicePaymentSourceType

settleInvoicePayment takes accountingMethod as a raw settings string, but
resolveInvoicePaymentSourceType requires the 'accrual' | 'cash' union.
Normalize at the call site (anything but 'cash' books as accrual), matching
the existing useCashEntry semantics.

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

* fix: address CodeRabbit review findings and nitpicks on PR #1004

Review findings:
- backup settings redirect: always force view=export over incoming params
- AGI/VAT kvittens crons: isolate best-effort post-submit calls, check the
  signed-state persist error, guard recovery calls in catch blocks so one
  company cannot abort the rest; surface grant_revoked in the run summary
- kvittens notifications: atomic claim-first dedup with a partial unique
  index; map non-uuid reference keys to deterministic uuids
- grant probe: record the actual 2xx status; mTLS transport: handle
  response-stream errors
- stripe: amount-aware idempotency keys for payment links; emit
  stripe.disconnected on upstream revocations
- ROT/RUT beslut import: mutate in-memory request state after apply, move
  item + header writes into an atomic apply_rot_rut_beslut RPC, add
  rot_rut_payout to JournalEntrySourceTypeSchema
- migrations: use NOT VALID + VALIDATE CONSTRAINT for CHECK constraints on
  journal_entries, notification_log and rot_rut_payout_requests
- cloud backup: hour_utc-only schedule updates clear stale hour_local

Nitpicks:
- stripe sync: enforce the cron time budget inside per-connection event
  processing with idempotent cursor progress; maybeSingle for settings;
  honest partial-customer DTO shared with the settlement boundary
- shared applyPaymentLinkToInvoice helper for both invoice send routes,
  v1 docblock documents step 6b and PAYMENT_LINK_FAILED
- settings panel: drop redundant decodeURIComponent
- cloud backup: document worst-case archive memory headroom

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:14:12 +02:00
Jakob Wennberg 3a2c57a167 feat(billing): paywall conversion pass (deferred first charge, trial touchpoint, sell-view upgrade) (#991)
* feat(billing): paywall conversion pass: deferred first charge, trial touchpoint, sell-view upgrade

- checkout passes subscription_data.trial_end (trial grant expiry, 49h floor)
  so a mid-trial upgrade costs 0 kr today instead of double-billing days the
  company already has free; billing/status counts 'trialing' as paying
- trial countdown pill in the sidebar (CompanyContext.trialEndsAt via
  getCompanyEntitlements); hidden for sandbox, dev bypass, and once any
  non-trial grant is active
- sell view: what-happens-when timeline, free-vs-paid comparison table,
  risk-reversal copy + chevron CTA, post-checkout confirmation state

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

* fix(billing): review triage: fail-closed trial lookup, hourly countdown refresh, BFL retention note

- checkout returns 500 (no Stripe session) when the trial-grant lookup errors,
  instead of silently charging immediately after the UI promised 0 kr idag
- sidebar trial countdown recomputes hourly so a long-lived tab stays honest
- sell-view retention copy states BFL 7-year retention explicitly
  (compliance-bot suggestion)

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

* chore: retrigger CI (pull_request event delivery stuck)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:29:48 +02:00
Jakob Wennberg 19cbb0094b fix(entitlements): gate the AI-only invoice-inbox for non-payers (#924)
The Dokumentinkorg (invoice-inbox) leaked past the paywall: visible in the
sidebar, command palette, and home "Att gora" list, its page directly
reachable, and every non-AI HTTP route open. Its whole value is AI field
extraction (Claude Sonnet 4.6 via Bedrock), already the paid chokepoint
elsewhere, so gate the whole surface on CAPABILITY.ai.

- EXTENSION_REQUIRED_CAPABILITY map + resolvers (keys.ts, sectors.ts) as the
  single source the nav item, the page, and the API dispatcher all read.
- Hide the sidebar item, command-palette entry, and home inbox row for
  non-payers; subtract inbox_document from the "Att gora" total via one shared
  visibleWorklistTotal helper (KPI tile + header cannot drift), clamped to >= 0.
- Block the /e/[sector]/[slug] page (fail-closed) with an upsell EmptyState.
- Enforce the capability in the extension API dispatcher (the single chokepoint
  that already enforces MFA), so every company-context inbox route 403s. The
  skipAuth /inbound webhook stays open (freeze-and-retain).
- FORCE_PAYWALL=true override so the real gate is exercisable in local dev.
- Tests: gating resolver, FORCE_PAYWALL, dispatcher 403/allow/webhook-exempt,
  visibleWorklistTotal, and enable-banking /connect + /sync 403.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:20:39 +02:00
Mattsson 2c2743eb79 Check/salary bankid api (#892)
* fix(bankid): harden login/signup flow — polling, signup rollback, metadata merge, enrichment lookup

- middleware: read BankID enrichment from the bankid_enrichment table (the
  extension_data path has been dead since the multi-tenant refactor), so
  company-less BankID users land on /select-company instead of the manual wizard
- BankIdAuth: hard 6-min poll deadline; every failed poll counts toward the
  give-up limit; guard overlapping ticks so completion runs exactly once
  (a double /complete regenerated the magic link and invalidated the first,
  failing logins intermittently); retry clicks wait out the start cooldown
  instead of silently no-oping; Swedish messages for 429/unknown start errors
- bankid/complete: all-or-nothing signup — delete the created user when the
  identity insert, app_metadata update, or magic-link generation fails, so a
  retry starts clean instead of hitting account_exists with an unusable account
- bankid/unlink: read-merge-write app_metadata so has_password survives unlink
  (BankID-only users could otherwise strand themselves with no login method)
- login: BankID "create account" CTA now links to /register instead of
  dismissing the notice; sv.json: fix missing å/ä/ö in settings_bankid strings

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

* docs: move secondary guides into docs/, delete dead root files

Move DOCKER.md, SELF-HOSTING.md, WHITELABEL.md and extensions.md
(renamed EXTENSIONS.md) into a new docs/ folder and update all path
references (README, setup.sh, .dockerignore image rules, docker-publish
workflow comment, _example-branding, lib/branding/service.ts).

Delete two dead root files: customer.json (stray API-test payload) and
findings.md (point-in-time swarm audit export, criticals already filed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(api): security & correctness hardening + withRouteContext MFA migration across API routes

Audit of ~100 app/api routes. Highlights:

Security
- agent/conversations: list leaked colleagues' titles + message previews
  (company-scoped RLS, no user filter) -> user-scoped
- calendar/feed PUT: raw body into .update() allowed feed_token fixation on a
  public unauthenticated URL -> strict schema, content toggles only
- bokslutsdispositioner: unbounded schablonintaktRate could inflate the
  IL 30 kap 25% periodiseringsfond cap base -> bounded
- agent profile/composer/onboarding: viewers could rewrite the agent profile
  while sibling /verify blocked them -> role-gated

Correctness
- account-totals / listAssets: unbounded queries silently truncated at 1000
  rows (under-counted money; skipped assets at year-end depreciation) ->
  fetchAllRows with stable order (+3 more pagination fixes)
- voucher-gaps: swallowed detect_voucher_gaps RPC errors (BFNAR gap view could
  show "no gaps" when the check never ran) -> surfaced
- 5 phantom-success writes (OK on zero matched rows) fixed
- assets K3 component-sum validated against stale acquisition_cost -> fixed
- invite silent email-send failure -> response carries email_sent;
  deadlines/calendar cast-then-check JSON crashes -> Zod

Convention
- ~44 legacy routes converted to withRouteContext (MFA); added Zod validation,
  corrected status codes, console.* -> lib/logger

Response shapes preserved for existing callers. ~110 new tests.

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

* feat(bookkeeping): save a booking as a reusable template from Bokför direkt

Add a "Spara som mall" action to the manual booking dialog so users can
capture a kontering they just worked out as a booking template — right
where they figured out how something should be booked.

- derive amount-parameterised template lines from the concrete booking
  (settlement = the non-VAT leg nearest the total, 26xx = a VAT line with
  its rate snapped to the nearest standard rate, the rest = business
  ratios; line labels come from the loaded BAS chart)
- extract the shared TemplateForm out of BookingTemplatesPanel so the
  booking dialog reuses the same editor, live preview and convertibility
  hints instead of duplicating them
- save via the existing POST /api/settings/booking-templates endpoint

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bokslut): render arsredovisning RR/BR at ÅRL post level — no kontonummer

Bolagsverket rejected a user's filed årsredovisning with "Balansräkning
och resultaträkning ska inte innehålla kontonummer": the PDF built every
statement row as per-account "1930 Företagskonto" lines while the iXBRL
filing path already aggregated to statutory posts, so the two artifacts
diverged.

The PDF statements now derive from the same K2 risbs mapping the iXBRL
document uses (mapTrialBalancesToK2), via a new statement-rows.ts that
emits post-level rows in uppställningsform order for both the K2 and K3
templates. Also fixed along the way:

- Jämförelseår column (ÅRL 3:5 §) — previous-year trial balances now load
  and render; the old PDF had no comparatives at all.
- mapping.warnings (unmapped accounts, RR ≠ 2099, obalans, reclass
  nudges) flow into ArsredovisningData.warnings so the wizard flags a
  non-fileable document before download.
- Flerårsöversikt current/previous year overridden with the mapper's
  strict-3000–3799 Nettoomsattning, mirroring build-input's
  duplicate-fact rule, so the FB table ties to the RR.
- FB eget kapital-table is post-level and drops obeskattade reserver
  (never eget kapital); K3 equity-changes statement uses real prior-year
  opening balances with derived utdelning/nyemission residuals that tie
  the roll-forward exactly to booked UB.
- build-input dedupes warnings now that the PDF path runs the same
  mapping.

Regression test asserts no RR/BR label ever contains a four-digit
account number again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reports): diagnose untransferred prior-year results behind balance-sheet differens

Prod incident (97 kr): a multi-year SIE migration lacked one year's
omforing av arets resultat; the residual corrupted every later derived
opening balance and Balansrakningen showed a bare "Differens: 97 kr"
with no explanation. Continuity checking cannot catch this failure mode
(prior-year UB and derived IB match per-account by construction) - the
invariant that actually breaks is per-year P&L = 0 for all non-latest
years.

- lib/reports/imbalance-diagnosis.ts: shared detector
  (findUntransferredResults + buildImbalanceDiagnosis)
- Balansrakning/Balansrapport attach imbalance_diagnosis when unbalanced,
  naming the exact culprit years; rendered in web views + PDF; MCP
  gnubok_get_balance_sheet inherits the field via spread
- SIE import: parse-time warning when a completed year's vouchers leave
  a P&L residual, plus a post-import DB walk surfacing culprits as
  warnings and structured details.untransferredResults; the Arcim
  migration workspace previously dropped result.warnings entirely and
  now renders them
- opening-balance/correct: pre-flight the company lock date and return
  409 OB_COMPANY_LOCK_DATE (retryable: false, lock date interpolated in
  the client message) instead of the retryable 500 that invited blind
  retries; catch-path maps a raced trigger rejection to the same code

Diagnosis runs only on unbalanced paths (zero cost when healthy) and
never fails the report or the import. No migration, nothing persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: production error remediation — FX rates, deadlines, log levels, correction relink

Batch of fixes for recurring Vercel runtime errors:

- Riksbanken FX rates: persistent read-through cache (exchange_rates
  table), one retry honoring Retry-After on 429/5xx, bounded ingest
  concurrency, and an honest fallback — most recent cached observation
  or null, never a hardcoded rate silently booked into amount_sek.
  Unrated transactions stay repairable via refresh-exchange-rate.
- Tax deadline regeneration inserts replacement rows before deleting
  the superseded set, so a failed insert no longer wipes a company's
  deadlines (the 23502 user_id regression did exactly that). Migration
  makes deadlines.user_id nullable for system-generated rows.
- Route wrappers + errorResponse log 4xx outcomes at warn so only
  genuine 5xx reach Vercel's runtime-error clustering; client-supplied
  /api/log telemetry demoted to warn as well.
- application/json documents (raw PSD2 responses archived per BFL)
  validate as parseable JSON with object/array root instead of always
  failing the magic-byte check.
- correctEntry surfaces document-relink failures to callers, and the
  BFL document-immutability trigger now allows relinking underlag from
  a reversed entry to its correction (migration + pg test).
- Middleware clears stale session cookies on /api requests too, using
  scope 'local' so cleanup doesn't re-trigger the failed token refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(skatteverket): persist token health and stop retrying dead consents

Terminal auth errors (SESSION_EXPIRED, REFRESH_EXHAUSTED, MISSING_SCOPE,
TOKEN_CORRUPTED) mark the token row needs_reconsent with the error code
and timestamp — SKV per-flow refresh tokens live 65 minutes, so once
expired nothing recovers without a fresh BankID consent. The AGI
kvittens and skattekonto sync crons skip flagged connections instead of
failing every night, and the settings panel prompts for re-consent
proactively. A successful reconnect resets the row to active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(banking): allocate distinct BAS ledger slots for PSD2 mirror accounts

A bank returning N same-currency accounts used to map them all onto the
currency default (1930/1932/1933/1934), tripping the UNIQUE
(company_id, ledger_account) constraint per-account — swallowed errors
left accounts silently unmirrored. allocatePsd2LedgerAccount now hands
out the currency default first, then free 1931–1959 sub-account slots,
skipping slots held by any existing row.

- Callback persists allocations to accounts_data so the picker pre-fills
  reality; reconnect reuses previously mirrored ledgers instead of
  re-deriving (a user remap to 1935 survives).
- Selection save resolves effective ledgers up front and rejects
  duplicates or cross-connection conflicts with a 400 instead of
  silently skipping the mirror.
- Bank error codes + psu_type are forwarded to the settings page for
  every OAuth error, keying the Handelsbanken corporate fullmakt
  guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent): stage exact journal lines on categorization previews

Categorization previews only carried debit/credit accounts, the GROSS
amount, and separate VAT rows — read together that looks like an
unbalanced 'gross on cost account + VAT debit' entry, and it misled
both users and agents into rejecting correct proposals. The MCP
preview and the pending-operation PATCH now materialize the exact
lines the commit executor will post (net cost line, VAT line, gross
bank line, SEK) via buildTransactionEntryLines, and PATCH re-derives
them from the new mapping instead of spreading stale staged lines.
ApprovalCard and /pending render the verifikat lines, falling back to
the legacy summary only for operations staged before this fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): prune unused imported accounts from the chart

SIE imports routinely bring in hundreds of accounts that were never
used and clutter the kontoplan. New account_usage_counts RPC (one
grouped query instead of a count per account) backs GET
/api/bookkeeping/accounts/usage, and POST /api/bookkeeping/accounts/prune
deletes zero-usage accounts — dry-run first, then an explicit account
list capped at 2000. Accounts with journal lines are skipped, never
deleted. The chart manager shows a usage column and a prune dialog
grouping custom accounts vs unused BAS-seeded ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): carry dimensions through v1 invoice and supplier-invoice surfaces

Credit-note creation now copies default_dimensions and per-line
dimensions from the original, so the reversing journal entry nets
against the same dimension cells instead of dropping them. List/detail
responses expose the dimension fields, and the OpenAPI spec snapshot
follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf: batch serial Supabase round-trips on hot dashboard paths

Every dashboard render pays the layout's query chain, so serialized
awaits are direct wall-clock: the layout, chat conversation, invoice
detail, supplier detail, select-company, and agent-onboarding pages now
run their independent lookups in parallel batches, and
getCompanyCapabilities folds its disabled-config read into the same
round-trip. JournalEntryList hydrates the saved fiscal-year scope
optimistically instead of serializing the first entries fetch behind
the fiscal-periods request. The supplier detail page filters invoices
server-side via a new supplier_id query param instead of fetching the
whole company ledger, and the invoice editor (with its framer-motion
dependency) lazy-loads so it stops shipping with the invoice list
bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(salary): one-click runs, payslip delivery, payments settings, run cockpit

Salary P1 batch, driving the 20-click flow toward 3 clicks:

- One-click 'Starta lönekörning': POST /api/salary/runs accepts an
  empty body and resolves defaults server-side — period follows the
  latest non-corrected run, payment date from the new
  salary_pay_day setting, series from the per-source-type map. The
  separate /salary/runs/new page is gone.
- Run detail page rebuilt as a step-railed cockpit (progress rail,
  KPI cards, employee ledger, journal preview) on a deliberately
  wider canvas; components extracted to components/salary/run/.
- Payslip delivery: tokenized public payslip pages (/payslip/[token],
  backed by salary_payslip_links) plus per-employee email send with
  PDF — employees need no account, and the middleware exempts the
  route from auth redirects.
- Payments settings: salary pay day, default bank, and pain.001 vs
  Bankgirot Lön format with per-bank upload instructions and an LB
  sunset warning (banks retire LB during 2026).
- AGI panel: full submission status flows (stale drafts, signing
  links, kvittens polling, error reports); tax payment panel with
  skattekonto shortcut and mark-as-paid.
- Salary calendar bulk editing, employee benefits/tax-card polish,
  municipality tax-table lookup improvements.

messages/sv+en also carry the strings for the account-prune,
skatteverket-reconsent, and banking surfaces committed just before
this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: adopt Next 16 proxy.ts convention + repo housekeeping

- Rename middleware.ts to proxy.ts with the proxy() export (Next 16
  renamed the middleware convention; behavior unchanged).
- Exclude dev_docs/ from tsconfig so stray snippets in planning docs
  don't break the build type-check.
- Ratchet antipatterns-baseline down (raw-route-auth 165 → 119) to
  lock in the withRouteContext migration from 5cfd2b76.
- template-library uses roundOre() instead of inline rounding.
- database.md: drop account_balances from the key-tables list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): robust service-role detection in correction document relink

relink_documents_to_correction() keyed its service-role branch on auth.role(),
which reads the singular request.jwt.claim.role GUC that PostgREST v10+ and the
pg-real harness no longer populate. Genuine service-role callers (pending-ops
executor / MCP approve) landed in the auth gate and could not relink underlag.
Read the role from the request.jwt.claims JSON directly, mirroring the canonical
link_voucher_rpcs_tenant_guard convention. Validated on staging.

Also: harden the salary run page's error paths (res.json().catch) against
non-JSON error bodies, and roll back the pg-real service-role case in finally so
an aborted transaction cannot poison a pooled connection for the next test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(documents): restore journal_entry_line_id link durability (BFL 7 kap)

Migration 20260704103000 rewrote enforce_document_journal_entry_immutability to
guard journal_entry_id but left journal_entry_line_id to the metadata trigger,
which exempts draft-linked docs -- and the entry-level trigger only fired on
UPDATE OF journal_entry_id, so a line-id-only UPDATE never invoked it at all.
That let a set journal_entry_line_id be cleared to NULL, breaking the "link
durable from first set" invariant (document-immutability.pg regression).

Widen the trigger to fire on journal_entry_line_id too and guard it with the
same uuid-durability rule as journal_entry_id (setting NULL -> uuid stays
allowed; clearing/re-pointing a set value is blocked, status-independent). The
correction-relink GUC path, which legitimately clears line_id when moving
underlag to the posted correction, stays exempt. Validated on staging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:05:09 +02:00
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00
Jakob Wennberg 6be6510d73 fix(entitlements): gate paid AI document OCR server-side (free-tier leak) (#852)
* fix(entitlements): gate paid AI document OCR server-side (free-tier leak)

Free/manual-tier companies could trigger paid Bedrock OCR (extractInvoiceFields)
with no `ai` capability check, on every transport:
- invoice-inbox HTTP paths — /upload + email /inbound (shared uploadAndExtract),
  /items/:id/attach-document, /items/:id/retry-extraction (4 call sites, zero
  capability refs);
- the gnubok_upload_document MCP tool — absent from MCP_TOOL_CAPABILITY_MAP, so a
  free-tier API key (incl. the claude.ai connector's minted gnubok_sk_ key) got
  unlimited AI extraction. This disproved the keys.ts "no MCP tool invokes AI"
  comment.

Fix (money-blocker for the free/paid tier cutover):
- Gate the 3 inbox call sites on hasCapability(CAPABILITY.ai). Upload + attach
  degrade gracefully (document still stored; extraction skipped with reason
  `no_ai_entitlement`, highest priority in the existing skipReason chain). Retry
  is an explicit "run AI now" action, so it hard-blocks with 403
  capabilityBlockedResponse.
- Register gnubok_upload_document -> CAPABILITY.ai in MCP_TOOL_CAPABILITY_MAP; the
  dispatcher already enforces the map. Correct the stale keys.ts comment and the
  misleading "deterministic field extraction" tool description + manifest copy
  (the extension migrated regex -> AI OCR).

Tests: no-AI upload/attach skip + retry 403 (sandbox-skip-extraction), retry 403
(retry-extraction), and the MCP map contract + refined dispatch<->commit parity
(capability-maps: upload_document is dispatch-only, no commit counterpart).

Self-hosted stays all-on (hasCapability short-circuits). No migration.
Follow-up (not in scope): capability-blind DashboardNav (free/paid rails identical)
and /chat gated on isVerified not `ai` — see dev_docs/nav_ia_redesign.md Part 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): assert gnubok_upload_document is ai-gated at dispatch

The gnubok_upload_document handler runs extractInvoiceFields (Bedrock OCR)
inline rather than through the entitlement-gated uploadAndExtract, so the
central MCP_TOOL_CAPABILITY_MAP dispatch check is the only paywall on that
transport. Lock it with a test (flagged by PR review as an untested money
path) so a free-tier connector key can never reach paid OCR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:36:15 +02:00
Mattsson f63d3e3100 Bug/open banking flow (#854)
* fix(enable-banking): pin Mobile BankID (decoupled) auth_method so Handelsbanken corporate connects

We never sent auth_method to Enable Banking, so it fell back to the ASPSP's
visible default — REDIRECT for Handelsbanken. For Handelsbanken *corporate*
PSUs the redirect flow does not support Mobile BankID, so authorization failed
right after the user approved in the BankID app. Mobile BankID at Handelsbanken
is a DECOUPLED method flagged hidden_method=true, which Enable Banking only uses
when requested explicitly.

Resolve the bank's preferred auth method before /auth: query the ASPSP's
auth_methods and pick the DECOUPLED (Mobile BankID) method when present,
otherwise leave auth_method unset so banks that already work are untouched.
The method name is read dynamically per psu_type, so it is robust across
sandbox/production naming.

- api-client: add approach/hidden_method to AuthMethod, fix ASPSP.auth_methods
  field name (was available_auth_methods, never populated), add
  getPreferredAuthMethod(), thread optional authMethod through startAuthorization
- index: resolve authMethod in /connect and pass it on both fresh + reconnect
- tests: cover method selection and request-body shaping

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(invoice-inbox): clean up bulk-selection toolbar UI

Redesign the selection toolbar shown when inbox items are checked:
one solid primary "Bokför valda" button with outlined secondary
actions ("Fråga assistenten", "Ta bort") and a plain selection
count. Removes the redundant "Avmarkera" button (users uncheck the
still-visible box), fixes label clipping, and gives the toolbar more
breathing room.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(entitlements): bypass paywall in local development

Add isPaywallBypassed() so all gated capabilities are testable locally
without a subscription. Fires only on NODE_ENV=development (npm run dev)
or an explicit DISABLE_PAYWALL=true escape hatch — production builds run
under NODE_ENV=production and the entitlement suite runs under 'test',
so both keep exercising the real gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tic): resolve enskild firma bolagsuppgifter via 12-digit personnummer

TIC's Lens search is fuzzy and only resolves an enskild firma from the 12-digit (century-prefixed) personnummer; a 10-digit form fuzzy-matched an unrelated entity. Expand personnummer to 12 digits before querying and reject hits whose registration number is unrelated to the request. Add a "Hämta" action to the settings Bolagsuppgifter panel to (re)fetch on demand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(transactions): implement categorize core for bank transaction categorization

- Added `categorize-core.ts` to handle categorization of bank transactions, supporting single and bulk operations.
- Introduced `categorizeMatchedTransaction` and `bulkBookMatchedInboxItems` functions for transaction processing.
- Implemented fiscal period validation and duplicate booking detection.
- Enhanced logging and error handling for transaction categorization.

feat(scripts): add diagnostic script for Handelsbanken ASPSP metadata

- Created `check-handelsbanken-aspsp.mjs` to fetch and display available authentication methods for Handelsbanken.
- Outputs metadata for business and personal PSU types, including default authentication methods.

fix(migrations): increase statement timeout for SIE bulk delete operations

- Updated `20260629160000_sie_bulk_delete_statement_timeout.sql` to set a longer statement timeout for bulk delete RPCs to prevent cancellations during large imports.

feat(migrations): add bulk book inbox items to pending operations

- Expanded `pending_operations` table to include `bulk_book_inbox_items` operation type in `20260630120000_pending_operations_add_bulk_book_inbox_items.sql`.
- Supports bulk booking of matched inbox items against bank transactions.

test(pg): add tests for replace_period_opening_balance_link RPC

- Implemented tests in `replace-period-opening-balance-link.pg.test.ts` to validate the functionality of the opening-balance correction flow.
- Ensured immutability of opening balance links and proper handling of posted vs. non-posted entries.

* fix(sie-export): update journal entries and lines handling in SIE export tests

* fix(migrations): resolve version collision on 20260629160000

The SIE bulk-delete statement_timeout migration shared version
20260629160000 with journal_entries_list_series_filter (merged from
main via #798/#823), causing a schema_migrations_pkey duplicate key
error on apply. Rename the branch's migration to 20260629160100.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compliance): resolve compliance-swarm + review findings

- opening-balance/correct: compensating rollback for the non-atomic
  storno+rebook so a mid-sequence failure never leaves two posted OB
  entries (ASVS V2.3); durable audit event on every failure path
  (V16); reference the original verifikationsnummer in the corrected
  entry per BFL 5 kap 5§; document that requireWrite already enforces
  write-role + membership (V8.2.1 was a false positive)
- reports sources routes: validate the cursor date component as ISO
  (/^\d{4}-\d{2}-\d{2}$/) before use, 400 on malformed (ASVS V1.2),
  applied to both the VAT-declaration and trial-balance routes
- AgentSessionList: await the rename PATCH, revert the optimistic
  title and toast on failure (ASVS V4.5)
- bank booking: exclude same-batch siblings from the booking-time
  duplicate guard so bulk-booking distinct same-(date,amount)
  transactions no longer false-positives; pre-existing duplicate
  detection is preserved
- BulkBookInboxDialog: drop the unsafe currency-based reverse_charge
  default, add an omvänd skattskyldighet advisory, and type VAT
  options to the backend VatTreatment union
- OpeningBalanceRowEditor: hold onChange in a ref (synced in effect,
  not during render) so an unstable callback can't cause a render loop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:13:00 +02:00
Jakob Wennberg db843a7a5b fix(entitlements): gate paid MCP tools (send_invoice/agi_submit/vat_declaration_submit) server-side (#846)
The HTTP routes call requireCapability at every paid chokepoint, but the
MCP/agent path bypassed the paywall entirely: the three external-service
tools stage operations whose commit calls the email / Skatteverket services
directly, with no capability check. After the 2026-07-07 trial cutover a
trial-connected non-payer using the gnubok MCP connector could still send
invoice emails and file AGI/VAT.

Close the gap with two layers, mirroring the existing TOOL_SCOPE_MAP gate:

- Dispatch gate (mcp-server/server.ts): MCP_TOOL_CAPABILITY_MAP, checked
  right after the scope check, blocks a non-entitled company before any
  pending op is staged. Emits errorKind='capability_denied' telemetry.
- Commit-time gate (commitPendingOperation): PAID_OPERATION_CAPABILITY_MAP,
  checked before the atomic claim. The real external-service chokepoint —
  applies to the MCP approve tool AND the UI approval path, and closes the
  trial-connected-token window (the grant has expired by commit time). A
  blocked op stays 'pending', so it is re-approvable once the company subscribes.

Adds a transport-free capabilityBlockedError() helper (shared bilingual
copy) and locks both maps with tests (maps, dispatch gate, commit gate).
Only the three write/submit tools are gated; SKV read/local tools stay free
per the statutory carve-out. No DB/migration change; self-hosted stays all-on.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:38:14 +02:00
Jakob Wennberg 4f0a7b1db0 feat(entitlements): per-company capability paywall — gate, trial seeding, UI upsells, Stripe checkout (#815)
* feat(entitlements): capability-grant gate substrate (paywall + modularity)

Two-axis capability primitive behind the SaaS paywall and the per-tenant
modularity/marketplace vision:
- migration: capability_grants (entitlement axis, polymorphic company/firm
  scope), company_capability_config (enablement axis), metered_events
  (append-only), company_has_capability() RPC reusing the 20260619130100
  tenant guard; SELECT-only RLS (writes service-role only, no self-grant).
- lib/entitlements: hasCapability/requireCapability gate (mirrors guardSandbox,
  fail-closed, NEXT_PUBLIC_SELF_HOSTED bypass), capability key namespace,
  metering helper.
- unit (11) + pg-real tests (RPC/RLS/tenant-guard incl. no-self-grant).

Gate not yet wired into call sites (follow-up commit). Paid keys:
ai, bank_sync, skatteverket, email_send.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(entitlements): enforce capability gate at paid external-service chokepoints

Wire the gate into the paid surfaces (keys: ai, email_send, bank_sync, skatteverket):
- AI routes (agent invoke/composer/onboarding stream): requireCapability(ai)
- Invoice send (web + v1): requireCapability(email_send)
- document-extraction event handler: skip Bedrock extract if ai not entitled
- enable-banking + skatteverket crons: per-company hasCapability skip in loop
- colocated send-route test mocks updated (requireCapability -> null)

Free per founder decision: TIC org lookup, VIES VAT validation, FX auto-fetch,
cloud backup, BankID login, all internal bookkeeping.

DEPLOY ORDER: fail-closed by design — do NOT deploy before trial/comp grant
seeding lands, or companies without grants lose these features. Seeding +
Stripe checkout/webhook are the next steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(entitlements): seed trial + comp capability grants

Makes the fail-closed gate safely deployable — nobody is locked out at cutover:
- AFTER INSERT trigger on companies grants every NEW company a 30-day trial on
  the PAID keys (ai, bank_sync, skatteverket, email_send), on ALL creation paths
  (RPC/MCP/direct) — so a new signup can use onboarding AI immediately.
- one-time backfill for EXISTING companies: created <=2026-06-07 -> trial ends
  2026-07-07; created later -> created_at + 30 days.
- permanent comp grants for Arcim/Mattsson (matched by name, no hardcoded UUIDs).
- pg tests: clearGrants() for controlled resolver tests + trigger coverage.

Trigger fn is SECURITY DEFINER so it writes grants regardless of caller RLS
(table has no INSERT policy for authenticated — no self-grant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(entitlements): client capability visibility + billing page

Non-payers get a clean upsell instead of broken/empty features:
- CompanyContext gains capabilities[] + useCapability(key); resolved once
  server-side in the dashboard layout via getCompanyCapabilities (batched, 2
  queries), all three provider branches wired.
- /settings/billing upgrade page — the destination upsells point to (Stripe
  Payment Link via NEXT_PUBLIC_STRIPE_PAYMENT_LINK; degrades to 'coming soon'
  until automated checkout lands).
- ChatEmptyState: non-payer sees an Uppgradera CTA (mirrors the sandbox state).
- SendInvoiceDialog: email send disabled + upsell note when email_send missing
  (extends the existing sandbox-disable pattern).

Fast-follow: chat input/FAB + document-inbox empty state + bank/skatteverket/
AI-suggest buttons + a shared capability_blocked->toast backstop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(entitlements): gate remaining paid UI surfaces with upsell (fast-follow)

disable-with-upsell across the rest of the paid surfaces (keys: bank_sync, skatteverket, ai):
- BankSyncNowButton: sync/reconnect disabled + note when !bank_sync (CSV/SIE stays free)
- AGIPanel: AGI submit-to-Skatteverket disabled + note when !skatteverket
- SkatteverketConnectPanel: BankID connect/reconnect disabled + upsell
- ApprovalCard: AI re-propose (correction) gated; manual approve/reject stay free
- InvoiceInboxWorkspace: upsell when extraction empty AND !ai (deterministic parse + manual entry unaffected)
- AgentTrigger FAB: routes to /settings/billing when !ai (no dead chat)
- settings nav: 'Abonnemang'/'Subscription' link to /settings/billing (sv/en)

TaxPaymentPanel + TransactionInboxCard intentionally untouched — only local/
deterministic actions there, nothing paid+external to gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(entitlements): automated Stripe subscription checkout + webhook

Self-serve revenue wired to the same capability-grant primitive:
- migration: company_subscriptions (company<->Stripe link/status) + stripe_webhook_events (idempotency)
- lib/stripe: getStripe singleton, plan->price mapping, subscription-sync (statusGrantsAccess / subscriptionToState / applySubscriptionState / handleStripeEvent). Active sub -> upsert source='stripe' grants for PAID keys (expiry = period_end + 3d grace); canceled/unpaid -> remove ONLY stripe grants (freeze-and-retain).
- routes: POST /api/billing/checkout (hosted subscription Checkout, company_id metadata), POST /api/billing/portal (Customer Portal), POST /api/stripe/webhook (raw-body signature verify, event-id dedup; handles checkout.session.completed + customer.subscription.*)
- billing page: real plan-toggle Checkout CTA / manage-subscription portal, gated on isStripeConfigured()
- adds stripe@22; unit tests for sync logic

Provisioning is webhook-driven (never trusts the success redirect). Needs env: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_MONTHLY, STRIPE_PRICE_YEARLY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(entitlements): validate UUIDs in capability filter + log webhook errors

Addresses PR review (Superagent Security / PR Agent):
- has-capability.ts: validate companyId/teamId as UUIDs before interpolating into the PostgREST .or() filter (fail-closed) — removes the latent injection vector flagged in the entitlement gate. Unit tests updated to use UUIDs.
- stripe/webhook: log processing failures with event id + type before the generic 500, so a failing webhook is visible to operators.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(salary): always-free AGI XML download for manual filing; only direct API submit is paid

Per founder decision on the swedish-compliance-review finding: AGI is a mandatory statutory filing, so producing/downloading the AGI XML must never be paywalled. Adds a free 'Ladda ner AGI-fil' button (generates + downloads the XML for manual upload to Skatteverket's e-service) on all tiers; the gated 'Skicka in underlag' stays the paid convenience (direct API submission — which also requires the paid BankID connection). Upsell reworded to point to the manual path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(entitlements): harden comp-grant match after prod verification

Verified Arcim/Mattsson in prod (pwxtzglxptnnvjrpixpg): the name match was case-sensitive (missed the active 'Arcim technology AB' lowercase variant) and would have granted 3 archived dupes. Now match by org_number (5595386219 / 5595719864) OR case-insensitive name, active companies only — hits exactly the 3 active comp companies, excludes archived dupes and the unrelated 'Amnäs Mattsson, Emil' enskild firma.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:56:19 +02:00