Commit Graph
7 Commits
Author SHA1 Message Date
Jakob WennbergandClaude Opus 4.8 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
Jakob WennbergandClaude Opus 4.7 20989379bb feat(sandbox,branding): prod-parity demo with AI gating + accounted rebrand (#585)
* feat(sandbox,branding): prod-parity demo with AI gating + accounted rebrand

Sandbox now ships with seeded suppliers, supplier invoices, an asset,
a verified agent_profile, and pending operations so the demo company
exercises every prod surface. Server-side `guardSandbox()` short-
circuits any AI or paid-external API call (Bedrock chat/composer,
Resend invoice send, Riksbanken FX, VIES, etc.) and the AgentSheet
swaps in a SandboxAgentPreview that explains what's gated and offers
a register CTA. DashboardContent no longer mounts the
NewUserChecklist when the agent is already built, fixing the path
that let sandbox users still trigger /onboarding/agent.

Visible branding flips from Gnubok to Accounted: new BrandWordmark
component (Hedvig Letters Serif 700), new app/icon.png + PWA icons
generated from the accounted icon, default appName updated. URLs,
header names, API key prefixes, hostnames, and event/cookie/
localStorage keys keep `gnubok` — the rebrand is visual only.

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

* fix(sandbox): hardcode supplier-invoice arrival numbers in seed

get_next_arrival_number is MAX(arrival_number) + 1 against the same
table we're about to insert into. Calling it twice before either row
lands made both calls return 1, which then violated the
(company_id, arrival_number) unique index — POST /api/sandbox/seed
500'd on first sandbox start.

The seeded company is brand new in this branch so 1 and 2 are
guaranteed unused; hardcoding side-steps the race entirely.

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

* fix(sandbox): set paid_amount=0 on unpaid supplier invoice

PostgREST normalizes the column set across rows in a bulk insert, so
the second supplier invoice (Espresso House, status=registered) was
being sent with paid_amount=null because the first row (Telia, paid)
set it. supplier_invoices.paid_amount is NOT NULL DEFAULT 0; the
default only kicks in when the column is *absent* from the payload,
not when it's explicitly null. Set it inline to side-step the
normalization.

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

* fix(sandbox): set actor_type=agent_chat on seeded pending_operations

pending_operations only allows user-scoped INSERTs via the
`pending_operations_chat_insert` policy, which requires
actor_type='agent_chat' alongside auth.uid()=user_id +
company membership. The seed was inserting with the default
actor_type='user', tripping the RLS check.

Also lift risk_level from preview_data (where it was unused) onto
the row itself, matching the column added in
20260430120000_pending_operations_actor_and_risk.

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

* fix(pr-review): address PR #585 review feedback

Fixes called out by the core-only CI check, Greptile, and the
compliance + Swedish-accounting bots:

- AGI Programnamn pinned back to 'gnubok' (CI blocker). The XML
  Skatteverket receives must keep the stable software identifier
  regardless of the visual rebrand — same rule as the v1 health
  endpoint's `service: 'gnubok'` literal.
- handleCreateAccount in SandboxAgentPreview + ChatEmptyState now
  wraps signOut() in try/catch so a transient Supabase failure
  doesn't strand the user on a dead button (greptile P2 × 2).
- /api/currency/rate hard-fails on missing companyId instead of
  conditionally skipping the sandbox guard (greptile P2 / compliance
  V8.2.1).
- topUpSandboxAdditions now delegates to ensureSandboxAgentProfile;
  the assistant persona lives in exactly one place across the seed,
  layout backfills, and top-up path (greptile P2 outside-diff /
  compliance SOC2 CC6.1).
- ensureSandboxAgentProfile drops the userId param and sets
  verified_by_user_id to NULL — synthetic seed data should not
  attribute verification to a real user (compliance V8.2.1 /
  GDPR Art. 25(2)). Errors now logged via the structured logger
  instead of being silently swallowed (V16).
- Sandbox seed swaps real-world company names (Telia, Espresso
  House) for clearly-synthetic Demo-prefixed brands using the
  5559... documentation org-number range (compliance A.8.33).
  Asset cost bumped 24 000 → 35 000 SEK so the demo clears the
  förbrukningsinventarier threshold and illustrates capitalization
  unambiguously (swedish-asset-accounting).
- Representation pending-operation preview corrected: VAT label
  fixed from 6% → 12%, and input VAT split between the avdragsgill
  (2641) and ej-avdragsgill (5811) portions to match
  swedish-vat / ML 8 kap rules (swedish-vat).

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

* fix(pr-review): seed preview consistency + AGI Programnamn constant

Two last review-bot items before merge:

- Sandbox seed: the representation pending-operation preview was
  splitting the 240 SEK café meal 60/180 between 5810 and 5811,
  which is wrong for a single attendee under the 300 SEK / person
  avdragsgill cap (ML 8 kap) — the entire amount is fully
  avdragsgill in that case. Collapse the preview to a single 5810
  + 2641 + 2440 entry so it matches the supplier_invoice_items row
  1:1 and stops teaching demo users an incorrect bookkeeping
  pattern.
- Hoist the AGI Programnamn 'gnubok' literal into a named constant
  with a comment pointing to potential future Skatteverket vendor
  registration (per the swedish-compliance bot's nit).

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

* fix(sandbox): avoid BFL duplicate-verification on pending op + fix VAT cap comment

Swedish-compliance bot caught two final nits:

- The pending operation for the Demokafé representation was using the
  same supplier_invoice_number as the already-seeded supplier_invoices
  row (88245). If the sandbox user approved the staged operation, the
  insert would have created (or attempted) a duplicate verification —
  BFL 5 kap. requires each affärshändelse be recorded exactly once.
  Swap the staged operation's invoice number to a distinct value
  (INKOMMANDE-2026-001) so approval cleanly creates a new row.
- The preview comment described the 300 SEK threshold as an
  "avdragsgill cap". The actual rule (ML 8 kap. 9 §) caps the
  deductible VAT at 25 % × 300 SEK × antal_personer = 75 SEK per
  person — the 300 SEK is the tax base, not the total. Math here is
  correct either way, but the comment now states the correct formula
  so future seed edits don't propagate the wrong understanding.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:19:41 +02:00
Mattsson e4488a900b feat: add user locale preference to user_preferences table (#555)
* feat: add user locale preference to user_preferences table

- Introduced a new column 'locale' in the user_preferences table to store per-user UI language preferences.
- Added a CHECK constraint to ensure only supported locales ('sv', 'en') are allowed.
- Triggered a schema reload notification for the changes.

chore: declare CSS module support in TypeScript

- Added a declaration for CSS modules in globals.d.ts to enable TypeScript support for importing CSS files.

* feat: add Swish as an invoice payment method in company settings
2026-05-21 21:33:22 +02:00
Mattsson fd1db89603 Fix/invoice numbers (#365)
* feat: make invoice_number nullable and assign on send

- Updated the invoices table to allow invoice_number to be nullable.
- Modified the logic to assign invoice numbers only when the invoice status transitions to 'sent'.
- Refactored related code to handle nullable invoice numbers, including UI components and API routes.
- Added tests to ensure correct behavior when handling invoices with null invoice numbers.
- Introduced a utility function to display invoice numbers, defaulting to '(Utkast)' for drafts.

* fix: update fiscal period handling to return names of open periods in error messages

* fix: enhance period creation logic to account for company-wide bookkeeping lock-through

* fix: remove unnecessary customer_type field from customer insertion query

* fix: scope invoice number count query to specific companies to avoid test interference

* feat: Implement atomic invoice number generation and ensure compliance with invoice numbering rules

- Introduced `ensureInvoiceNumber` function to assign invoice numbers atomically, handling concurrency and ensuring compliance with document types.
- Updated invoice-related components to utilize the new `invoiceNumberDisplay` utility for consistent invoice number formatting.
- Added checks to ensure that invoices in non-draft statuses have valid invoice numbers, preventing violations of legal requirements.
- Created tests for the new invoice number generation logic, ensuring correct behavior under various scenarios, including concurrent requests.
- Added a draft banner to PDF templates for invoices without assigned numbers, clarifying their status to users.
- Updated database migrations to support the new atomic invoice number generation logic and enforce constraints on invoice statuses.
2026-04-27 16:29:58 +02:00
MattssonandClaude Opus 4.7 aec14f0ca7 fix: scope company_settings query to active company in invoice dialogs (#288)
SendInvoiceDialog and PaymentBookingDialog called .single() on
company_settings without a company_id filter, relying on RLS to narrow
the result. RLS uses user_company_ids() which returns every company the
user can access, so multi-company users (including team members) got
multiple rows back and PostgREST returned 406. Filter explicitly by the
active company and use maybeSingle() so a missing row falls back to
defaults.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 08:34:59 +02:00
Jakob WennbergandClaude Opus 4.6 e2d9c8bb85 fix: MCP OAuth 303 redirect, send dialog auto-close, bank details null payload (#175)
* fix: MCP OAuth 303 redirect, send dialog auto-close, bank details null payload

- OAuth authorize: use 303 See Other instead of default 307, which
  preserved POST method and caused Claude's callback to return 405
- SendInvoiceDialog: close dialog and show toast after email send
  instead of leaving a success message that requires manual close
- BankDetailsSetupDialog: omit empty fields from payload instead of
  sending null, which fails Zod validation on non-nullable schema fields

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

* refactor: remove dead sentMessage state and fix stale comment

Remove sentMessage state, its success banner JSX, and the CheckCircle2
import — all unreachable after the dialog now auto-closes on email send.
Fix stale "to null" comment in BankDetailsSetupDialog.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:27:40 +02:00
3050743013 Last feedback fixes (#116)
* Transaction updates. Email updates. Bookkeeping updates.

* Added email confirmation box

* Update components/invoices/SendInvoiceDialog.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update app/(dashboard)/invoices/[id]/page.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: restore deleted useEffect and remove stray JSX closing tag

The SendInvoiceDialog useEffect body was accidentally removed in e32c9b3,
leaving only the dependency array. Also removes a stray `/>` in the
invoice detail page that broke the Turbopack build.

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:31:34 +01:00