From 4f0a7b1db06f3921b74665f4f30343d6530f2e50 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:56:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(entitlements):=20per-company=20capability?= =?UTF-8?q?=20paywall=20=E2=80=94=20gate,=20trial=20seeding,=20UI=20upsell?= =?UTF-8?q?s,=20Stripe=20checkout=20(#815)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- app/(dashboard)/layout.tsx | 6 + app/(dashboard)/settings/billing/page.tsx | 83 +++++++ app/api/agent/composer/route.ts | 5 + app/api/agent/invoke/route.ts | 5 + app/api/agent/onboarding/stream/route.ts | 5 + app/api/billing/checkout/route.ts | 66 ++++++ app/api/billing/portal/route.ts | 42 ++++ .../enable-banking/sync/cron/route.ts | 7 + .../skatteverket/agi/kvittenser/cron/route.ts | 7 + .../skattekonto/sync/cron/route.ts | 7 + .../[id]/send/__tests__/route.test.ts | 4 + app/api/invoices/[id]/send/route.ts | 5 + app/api/stripe/webhook/route.ts | 58 +++++ .../[id]/send/__tests__/route.test.ts | 4 + .../[companyId]/invoices/[id]/send/route.ts | 5 + components/agent/AgentTrigger.tsx | 29 ++- components/agent/ApprovalCard.tsx | 26 ++- components/agent/ChatEmptyState.tsx | 27 ++- .../general/InvoiceInboxWorkspace.tsx | 40 ++++ components/invoices/SendInvoiceDialog.tsx | 23 +- components/salary/AGIPanel.tsx | 65 +++++- components/settings/BillingActions.tsx | 83 +++++++ .../settings/SkatteverketConnectPanel.tsx | 23 +- components/settings/useSettingsNavItems.ts | 1 + components/transactions/BankSyncNowButton.tsx | 55 +++-- contexts/CompanyContext.tsx | 15 ++ .../general/document-extraction/index.ts | 7 + .../__tests__/capability-grants.pg.test.ts | 214 ++++++++++++++++++ .../__tests__/has-capability.test.ts | 146 ++++++++++++ lib/entitlements/has-capability.ts | 168 ++++++++++++++ lib/entitlements/keys.ts | 55 +++++ lib/entitlements/metering.ts | 33 +++ .../__tests__/subscription-sync.test.ts | 137 +++++++++++ lib/stripe/client.ts | 31 +++ lib/stripe/subscription-sync.ts | 164 ++++++++++++++ messages/en.json | 1 + messages/sv.json | 1 + package-lock.json | 18 ++ package.json | 1 + ...0_capability_grants_and_metered_events.sql | 189 ++++++++++++++++ ..._seed_trial_and_comp_capability_grants.sql | 85 +++++++ .../20260629140000_stripe_subscriptions.sql | 44 ++++ 42 files changed, 1954 insertions(+), 36 deletions(-) create mode 100644 app/(dashboard)/settings/billing/page.tsx create mode 100644 app/api/billing/checkout/route.ts create mode 100644 app/api/billing/portal/route.ts create mode 100644 app/api/stripe/webhook/route.ts create mode 100644 components/settings/BillingActions.tsx create mode 100644 lib/entitlements/__tests__/capability-grants.pg.test.ts create mode 100644 lib/entitlements/__tests__/has-capability.test.ts create mode 100644 lib/entitlements/has-capability.ts create mode 100644 lib/entitlements/keys.ts create mode 100644 lib/entitlements/metering.ts create mode 100644 lib/stripe/__tests__/subscription-sync.test.ts create mode 100644 lib/stripe/client.ts create mode 100644 lib/stripe/subscription-sync.ts create mode 100644 supabase/migrations/20260628140000_capability_grants_and_metered_events.sql create mode 100644 supabase/migrations/20260629120000_seed_trial_and_comp_capability_grants.sql create mode 100644 supabase/migrations/20260629140000_stripe_subscriptions.sql diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index c1b44277..45348691 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -13,6 +13,7 @@ import { SandboxBanner } from '@/components/dashboard/SandboxBanner' import { getExtensionNavItems } from '@/lib/extensions/sectors' import { CompanyProvider } from '@/contexts/CompanyContext' import { getActiveCompanyId } from '@/lib/company/context' +import { getCompanyCapabilities } from '@/lib/entitlements/has-capability' import { getBranding } from '@/lib/branding/service' import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent' import { countPendingOperations, countUnbookedTransactions } from '@/lib/worklist' @@ -93,6 +94,7 @@ export default async function DashboardLayout({ isTeamMember, team, isSandbox: false, + capabilities: [], }} > @@ -147,6 +149,7 @@ export default async function DashboardLayout({ isTeamMember, team, isSandbox: false, + capabilities: [], } return ( @@ -181,6 +184,7 @@ export default async function DashboardLayout({ pendingOpsCount, { data: agentProfileIdentity }, { data: userProfile }, + capabilities, ] = await Promise.all([ supabase .from('company_settings') @@ -203,6 +207,7 @@ export default async function DashboardLayout({ // popover (full_name + initial) so it's clear which user is logged // in, distinct from the active company shown at the top. supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(), + getCompanyCapabilities(supabase, companyId), ]) // If onboarding incomplete, still render the dashboard — the page component @@ -261,6 +266,7 @@ export default async function DashboardLayout({ isTeamMember, team, isSandbox, + capabilities, } return ( diff --git a/app/(dashboard)/settings/billing/page.tsx b/app/(dashboard)/settings/billing/page.tsx new file mode 100644 index 00000000..196f9a7c --- /dev/null +++ b/app/(dashboard)/settings/billing/page.tsx @@ -0,0 +1,83 @@ +import { Check } from 'lucide-react' +import { createClient } from '@/lib/supabase/server' +import { getActiveCompanyId } from '@/lib/company/context' +import { statusGrantsAccess } from '@/lib/stripe/subscription-sync' +import { isStripeConfigured } from '@/lib/stripe/client' +import { BillingActions } from '@/components/settings/BillingActions' + +export const metadata = { title: 'Abonnemang' } + +// What the paid tier unlocks (mirrors lib/entitlements PAID_CAPABILITIES). +const INCLUDED = [ + 'AI-assistent: chatt, kategorisering och dokumenttolkning', + 'Bankkoppling och automatisk synk (PSD2)', + 'Skatteverket: moms- och AGI-inlämning', + 'E-postutskick av fakturor, påminnelser och lönebesked', +] + +// Free for everyone — reassures users that the core ledger is never withheld. +const ALWAYS_FREE = + 'All bokföring, fakturering, rapporter, SIE-export, org.nr-uppslag och momsnummerkontroll ingår alltid utan kostnad.' + +/** + * Upgrade / subscription page — the destination every "Uppgradera" affordance + * points to. Reads the active company's subscription status to show either the + * Checkout CTA or the manage-subscription portal; fulfilment itself happens via + * the Stripe webhook, never this page. + */ +export default async function BillingPage() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + let isActive = false + if (user) { + const companyId = await getActiveCompanyId(supabase, user.id) + if (companyId) { + const { data: sub } = await supabase + .from('company_subscriptions') + .select('status') + .eq('company_id', companyId) + .maybeSingle() + isActive = statusGrantsAccess((sub as { status: string | null } | null)?.status) + } + } + const configured = isStripeConfigured() + + return ( +
+

Abonnemang

+

+ {isActive + ? 'Ditt abonnemang är aktivt. Du kan hantera eller avsluta det när som helst.' + : 'Lås upp AI-assistenten, bankkoppling, Skatteverket-inlämning och e-postutskick.'} +

+ +
+
+ 199 kr + / månad +
+

+ eller 1 999 kr per år (två månader gratis). +

+ +
    + {INCLUDED.map((item) => ( +
  • + + {item} +
  • + ))} +
+ +
+ +
+
+ +

{ALWAYS_FREE}

+
+ ) +} diff --git a/app/api/agent/composer/route.ts b/app/api/agent/composer/route.ts index e1bd3ba3..77eb2b38 100644 --- a/app/api/agent/composer/route.ts +++ b/app/api/agent/composer/route.ts @@ -5,6 +5,8 @@ import { getActiveCompanyId } from '@/lib/company/context' import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent' import { composeAgentProfile } from '@/lib/agent/composer' import { guardSandbox } from '@/lib/sandbox/guard' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' const BodySchema = z.object({ // Optional override; if absent we use the user's active_company_id. @@ -67,6 +69,9 @@ export async function POST(request: Request) { const blocked = await guardSandbox(supabase, companyId) if (blocked) return blocked + const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.ai) + if (capBlocked) return capBlocked + try { const composed = await composeAgentProfile(supabase, companyId, { dryRun: body.dry_run }) return NextResponse.json({ data: composed }) diff --git a/app/api/agent/invoke/route.ts b/app/api/agent/invoke/route.ts index d2fb0e80..0836e1e9 100644 --- a/app/api/agent/invoke/route.ts +++ b/app/api/agent/invoke/route.ts @@ -7,6 +7,8 @@ import { getIntent } from '@/lib/agent/intents/registry' import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent' import { runChatTurn, friendlyModelError } from '@/lib/agent/chat/run-turn' import { guardSandbox } from '@/lib/sandbox/guard' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' // Make sure extensions are loaded — the chat loop dispatches against the // agent tool registry which is populated by the mcp-server extension at load. @@ -111,6 +113,9 @@ export async function POST(request: Request) { const blocked = await guardSandbox(supabase, companyId) if (blocked) return blocked + const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.ai) + if (capBlocked) return capBlocked + // onboarding.intake completion signal — once the user has actually // engaged (typed a real reply, not the auto-fired greeting prompt that // mounts the chat), stamp intake_completed_at on the profile so re-entry diff --git a/app/api/agent/onboarding/stream/route.ts b/app/api/agent/onboarding/stream/route.ts index 913d8b8d..7376e694 100644 --- a/app/api/agent/onboarding/stream/route.ts +++ b/app/api/agent/onboarding/stream/route.ts @@ -3,6 +3,8 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { getActiveCompanyId } from '@/lib/company/context' import { guardSandbox } from '@/lib/sandbox/guard' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent' import { gatherComposerInputs, inputsToSourceSignals } from '@/lib/agent/composer/inputs' import { selectAtoms } from '@/lib/agent/composer/atom-selection' @@ -106,6 +108,9 @@ export async function POST(request: Request) { const blocked = await guardSandbox(supabase, companyId) if (blocked) return blocked + const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.ai) + if (capBlocked) return capBlocked + const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder() diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts new file mode 100644 index 00000000..43ee4725 --- /dev/null +++ b/app/api/billing/checkout/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireCompanyId } from '@/lib/company/context' +import { createServiceClient } from '@/lib/supabase/server' +import { getStripe, priceIdForPlan, type BillingPlan } from '@/lib/stripe/client' + +/** + * Create a Stripe subscription Checkout Session and return its hosted URL. + * The client redirects to it; provisioning happens via the webhook on + * checkout.session.completed (never trust the success redirect for fulfilment). + */ +export async function POST(request: Request) { + const { user, supabase, error } = await requireAuth() + if (error) return error + + let companyId: string + try { + companyId = await requireCompanyId(supabase, user.id) + } catch { + return NextResponse.json({ error: 'No company context' }, { status: 400 }) + } + + const body = (await request.json().catch(() => ({}))) as { plan?: string } + const plan: BillingPlan = body.plan === 'yearly' ? 'yearly' : 'monthly' + const priceId = priceIdForPlan(plan) + if (!priceId) { + return NextResponse.json({ error: 'Stripe price not configured' }, { status: 500 }) + } + + const stripe = getStripe() + const service = createServiceClient() + + // Reuse the company's Stripe customer if we already created one. + const { data: existing } = await service + .from('company_subscriptions') + .select('stripe_customer_id') + .eq('company_id', companyId) + .maybeSingle() + + let customerId = (existing as { stripe_customer_id: string | null } | null)?.stripe_customer_id ?? null + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email ?? undefined, + metadata: { company_id: companyId }, + }) + customerId = customer.id + await service + .from('company_subscriptions') + .upsert({ company_id: companyId, stripe_customer_id: customerId }, { onConflict: 'company_id' }) + } + + const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '' + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + customer: customerId, + line_items: [{ price: priceId, quantity: 1 }], + client_reference_id: companyId, + metadata: { company_id: companyId }, + subscription_data: { metadata: { company_id: companyId } }, + allow_promotion_codes: true, + success_url: `${appUrl}/settings/billing?success=1`, + cancel_url: `${appUrl}/settings/billing?canceled=1`, + }) + + return NextResponse.json({ url: session.url }) +} diff --git a/app/api/billing/portal/route.ts b/app/api/billing/portal/route.ts new file mode 100644 index 00000000..067ded33 --- /dev/null +++ b/app/api/billing/portal/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireCompanyId } from '@/lib/company/context' +import { createServiceClient } from '@/lib/supabase/server' +import { getStripe } from '@/lib/stripe/client' + +/** + * Create a Stripe Billing Customer Portal session so the user can manage, + * upgrade/downgrade, or cancel their subscription. Stripe handles all the + * compliance/PCI surface — we never build those flows ourselves. + */ +export async function POST() { + const { user, supabase, error } = await requireAuth() + if (error) return error + + let companyId: string + try { + companyId = await requireCompanyId(supabase, user.id) + } catch { + return NextResponse.json({ error: 'No company context' }, { status: 400 }) + } + + const service = createServiceClient() + const { data: sub } = await service + .from('company_subscriptions') + .select('stripe_customer_id') + .eq('company_id', companyId) + .maybeSingle() + + const customerId = (sub as { stripe_customer_id: string | null } | null)?.stripe_customer_id + if (!customerId) { + return NextResponse.json({ error: 'No subscription to manage' }, { status: 400 }) + } + + const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '' + const portal = await getStripe().billingPortal.sessions.create({ + customer: customerId, + return_url: `${appUrl}/settings/billing`, + }) + + return NextResponse.json({ url: portal.url }) +} diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 3fb72e10..bfb4fcf1 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -10,6 +10,8 @@ import { generateConsentExpiryEmailSubject, } from '@/lib/email/consent-notification-templates' import { ensureInitialized } from '@/lib/init' +import { hasCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import { withCronContext } from '@/lib/api/with-cron-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { getBranding } from '@/lib/branding/service' @@ -92,6 +94,11 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => { break } + if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.bank_sync))) { + ctx.log.info('skip — capability not entitled', { companyId: connection.company_id }) + continue + } + try { const daysLeft = getDaysUntilExpiry(connection.consent_expires) const isExpired = daysLeft !== null && daysLeft <= 0 diff --git a/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts index d1d8e6e6..edae2a48 100644 --- a/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts +++ b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts @@ -5,6 +5,8 @@ import { verifyCronSecret } from '@/lib/auth/cron' import { agiGetKvittenser } from '@/extensions/general/skatteverket/lib/agi-client' import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client' import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format' +import { hasCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' ensureInitialized() @@ -90,6 +92,11 @@ export async function GET(request: Request) { const declarationId = decl.id as string const period = formatRedovisningsperiod('monthly', decl.period_year as number, decl.period_month as number) + if (!(await hasCapability(supabase, companyId, CAPABILITY.skatteverket))) { + console.info('[agi-kvittenser-cron] skip — capability not entitled', { companyId }) + continue + } + try { // The token table is user-scoped (one BankID identity per user) but // also carries company_id. Match on company_id so a multi-company diff --git a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts index 1a9a795b..ec2d130f 100644 --- a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts +++ b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts @@ -2,6 +2,8 @@ import { createClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { verifyCronSecret } from '@/lib/auth/cron' +import { hasCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import { createExtensionContext } from '@/lib/extensions/context-factory' import { syncSkattekonto, SKATTEKONTO_LAST_SYNCED_AT_KEY } from '@/extensions/general/skatteverket/lib/skattekonto-sync' import { computeSkattekontoDrift, maybeAlertDrift } from '@/extensions/general/skatteverket/lib/skattekonto-drift' @@ -94,6 +96,11 @@ export async function GET(request: Request) { continue } + if (!(await hasCapability(supabase, companyId, CAPABILITY.skatteverket))) { + console.info('[skattekonto-sync-cron] skip — capability not entitled', { companyId }) + continue + } + try { // Cooldown: skip if synced within the last hour. const { data: lastSyncRow } = await supabase diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 62401062..272a12a9 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -75,6 +75,10 @@ vi.mock('@/lib/sandbox/guard', () => ({ sandboxBlockedResponse: vi.fn(), })) +vi.mock('@/lib/entitlements/has-capability', () => ({ + requireCapability: vi.fn().mockResolvedValue(null), +})) + import { POST } from '../route' describe('POST /api/invoices/[id]/send', () => { diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 7d8cc18a..4939be60 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -17,6 +17,8 @@ import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { guardSandbox } from '@/lib/sandbox/guard' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' ensureInitialized() @@ -33,6 +35,9 @@ export const POST = withRouteContext( const blocked = await guardSandbox(supabase, companyId) if (blocked) return blocked + const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.email_send) + if (capBlocked) return capBlocked + const emailService = getEmailService() if (!emailService.isConfigured()) { return errorResponseFromCode('INVOICE_SEND_EMAIL_NOT_CONFIGURED', opLog, { requestId }) diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts new file mode 100644 index 00000000..71b1db91 --- /dev/null +++ b/app/api/stripe/webhook/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from 'next/server' +import type Stripe from 'stripe' +import { getStripe } from '@/lib/stripe/client' +import { createServiceClient } from '@/lib/supabase/server' +import { handleStripeEvent } from '@/lib/stripe/subscription-sync' + +// Unauthenticated by design — authenticity comes from the Stripe signature, not +// a session. The route reads the RAW body (req.text()); parsing as JSON first +// would change the byte representation and break signature verification. +export async function POST(request: Request) { + const secret = process.env.STRIPE_WEBHOOK_SECRET + if (!secret) { + return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 }) + } + + const sig = request.headers.get('stripe-signature') + if (!sig) { + return NextResponse.json({ error: 'Missing signature' }, { status: 400 }) + } + + const rawBody = await request.text() + let event: Stripe.Event + try { + event = getStripe().webhooks.constructEvent(rawBody, sig, secret) + } catch { + return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }) + } + + const service = createServiceClient() + + // Idempotency: skip events we've already fully processed. The handler itself + // is idempotent too (upserts), so a concurrent double-delivery is also safe. + const { data: already } = await service + .from('stripe_webhook_events') + .select('event_id') + .eq('event_id', event.id) + .maybeSingle() + if (already) { + return NextResponse.json({ received: true, duplicate: true }) + } + + try { + await handleStripeEvent(service, getStripe(), event) + // Mark processed only AFTER success, so a failure lets Stripe retry. + await service.from('stripe_webhook_events').insert({ event_id: event.id, type: event.type }) + } catch (err) { + // Log with context before the generic 500 so a failing webhook is visible + // to operators (Stripe will retry on the non-2xx). + console.error('[stripe-webhook] processing failed', { + eventId: event.id, + type: event.type, + error: err instanceof Error ? err.message : String(err), + }) + return NextResponse.json({ error: 'processing_failed' }, { status: 500 }) + } + + return NextResponse.json({ received: true }) +} diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts index c20c3c56..f09bd867 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts @@ -83,6 +83,10 @@ vi.mock('@/lib/sandbox/guard', () => ({ isSandboxCompany: vi.fn().mockResolvedValue(false), sandboxBlockedResponse: vi.fn(), })) + +vi.mock('@/lib/entitlements/has-capability', () => ({ + requireCapability: vi.fn().mockResolvedValue(null), +})) import { InvoicePDF } from '@/lib/invoices/pdf-template' import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index 316fb074..5df874e8 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -55,6 +55,8 @@ import { uploadDocument } from '@/lib/core/documents/document-service' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { eventBus } from '@/lib/events' import { guardSandbox } from '@/lib/sandbox/guard' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types' const INVOICE_SEND_RESPONSE_COLUMNS = @@ -142,6 +144,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const blocked = await guardSandbox(ctx.supabase, ctx.companyId!) if (blocked) return blocked + const capBlocked = await requireCapability(ctx.supabase, ctx.companyId!, CAPABILITY.email_send) + if (capBlocked) return capBlocked + // Step 1: email service configured? const emailService = getEmailService() if (!emailService.isConfigured()) { diff --git a/components/agent/AgentTrigger.tsx b/components/agent/AgentTrigger.tsx index a75552e7..7859f825 100644 --- a/components/agent/AgentTrigger.tsx +++ b/components/agent/AgentTrigger.tsx @@ -1,9 +1,11 @@ 'use client' import { useAgentSheet } from './AgentSheetProvider' -import { usePathname } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' import AgentAvatar from './AgentAvatar' import { routeToIntent } from '@/lib/agent/intents/route-mapping' +import { useCapability } from '@/contexts/CompanyContext' +import { CAPABILITY } from '@/lib/entitlements/keys' // Floating trigger sits above the page bottom-right, opens the AgentSheet when // clicked. Hidden when the sheet is already open so the icon doesn't double up. @@ -25,6 +27,8 @@ import { routeToIntent } from '@/lib/agent/intents/route-mapping' export default function AgentTrigger() { const { openAgentSheet, isOpen, identity } = useAgentSheet() const pathname = usePathname() + const router = useRouter() + const hasAi = useCapability(CAPABILITY.ai) if (isOpen) return null // The /chat surface IS the chat — a floating "Fråga …" pill on top of it @@ -48,18 +52,25 @@ export default function AgentTrigger() { const name = identity.displayName?.trim() || 'min assistent' const dispatch = routeToIntent(pathname) - const labelText = dispatch.labelSuffix - ? `Fråga ${name} ${dispatch.labelSuffix}` - : `Fråga ${name}` + // AI assistant runs on a paid cloud service. Without the capability, opening + // the sheet would land the user in a chat whose send is dead. Keep the FAB + // visible (it's the conversion surface) but route it to billing instead. + const labelText = !hasAi + ? `Uppgradera för att använda ${name}` + : dispatch.labelSuffix + ? `Fråga ${name} ${dispatch.labelSuffix}` + : `Fråga ${name}` return ( + + ) + } + // Hidden on mobile — the sidebar IS the page when no conversation is open. // On desktop, fills the right pane with a centered prompt. return ( diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index ddb613b2..360b821a 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -32,10 +32,13 @@ import { Circle, X, ChevronDown, + Sparkles, } from 'lucide-react' import Link from 'next/link' import { cn, formatCurrency } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' +import { useCapability } from '@/contexts/CompanyContext' +import { CAPABILITY } from '@/lib/entitlements/keys' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import type { InvoiceExtractionResult } from '@/types' import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog' @@ -105,6 +108,24 @@ function pickSupplierName(item: InboxItem): string | null { return item.extracted_data?.supplier?.name ?? null } +// True when extraction produced at least one usable field. Distinguishes a +// deterministically-parsed underlag (fields present — render the editable +// list) from an item whose extracted_data is null/empty (AI never ran, or ran +// and found nothing). Currency is ignored because emptyExtraction() seeds it +// to 'SEK', so it is never a sign that extraction actually happened. +function hasAnyExtractedField(data: InvoiceExtractionResult | null): boolean { + if (!data) return false + const s = data.supplier + const inv = data.invoice + const t = data.totals + return Boolean( + s?.name || s?.orgNumber || s?.vatNumber || s?.bankgiro || s?.plusgiro || + inv?.invoiceNumber || inv?.invoiceDate || inv?.dueDate || inv?.paymentReference || + t?.subtotal != null || t?.vatAmount != null || t?.total != null || + (data.lineItems?.length ?? 0) > 0 || (data.vatBreakdown?.length ?? 0) > 0 + ) +} + // Lifecycle stage of an inbox item. Single source of truth shared by the list // filter, the count pills, and the row icons so they never drift apart. // @@ -1427,6 +1448,7 @@ function FieldsRail({ onRetryRequested: () => Promise }) { const { toast } = useToast() + const hasAi = useCapability(CAPABILITY.ai) const data = item.extracted_data const isProcessed = !!item.created_supplier_invoice_id const isBookedDirectly = !isProcessed && !!item.created_journal_entry_id @@ -1557,6 +1579,24 @@ function FieldsRail({ ))} + ) : !hasAnyExtractedField(data) && !hasAi ? ( + // No fields were extracted (AI never ran) and the company doesn't have + // the AI capability. Show an upsell in place of the blank field list — + // upload and manual entry stay available via the actions below. +
+
+ + AI-tolkning ingår i abonnemanget +
+

+ Uppgradera för att låta accounted läsa av leverantör, belopp och + moms automatiskt. Du kan fortfarande fylla i fälten manuellt eller + koppla dokumentet till en transaktion nedan. +

+ +
) : ( )} + {!isSandbox && !canEmail && mode === 'email' && ( +
+ E-postutskick kräver ett abonnemang.{' '} + + Uppgradera + {' '} + eller använd «Markera som skickad». +
+ )} {showJournalPreview ? ( <>

@@ -258,9 +269,15 @@ export default function SendInvoiceDialog({ + ) + } + + if (!configured) { + return ( + + ) + } + + return ( +

+
+ {(['monthly', 'yearly'] as BillingPlan[]).map((p) => ( + + ))} +
+
+ +
+
+ ) +} diff --git a/components/settings/SkatteverketConnectPanel.tsx b/components/settings/SkatteverketConnectPanel.tsx index f89f1e09..57ab3442 100644 --- a/components/settings/SkatteverketConnectPanel.tsx +++ b/components/settings/SkatteverketConnectPanel.tsx @@ -6,6 +6,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { useToast } from '@/components/ui/use-toast' +import { useCapability } from '@/contexts/CompanyContext' +import { CAPABILITY } from '@/lib/entitlements/keys' import { CheckCircle2, ExternalLink, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react' type Environment = 'test' | 'prod' @@ -25,6 +27,7 @@ type Status = export function SkatteverketConnectPanel() { const t = useTranslations('settings_skatteverket_connect') const { toast } = useToast() + const hasSkatteverket = useCapability(CAPABILITY.skatteverket) const [status, setStatus] = useState(null) const [loading, setLoading] = useState(true) const [disconnecting, setDisconnecting] = useState(false) @@ -118,7 +121,19 @@ export function SkatteverketConnectPanel() { code: (chunks) => {chunks}, })} - @@ -204,7 +219,11 @@ export function SkatteverketConnectPanel() {
{(status.expired || !status.canRefresh || !scopes.includes('skattekonto') || !scopes.includes('agd')) && ( - diff --git a/components/settings/useSettingsNavItems.ts b/components/settings/useSettingsNavItems.ts index a046131e..bc39ed40 100644 --- a/components/settings/useSettingsNavItems.ts +++ b/components/settings/useSettingsNavItems.ts @@ -47,6 +47,7 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti // Importera/Exportera. Team stays hidden (show:false) until enabled. const defs: Array = [ { id: 'account', href: '/settings/account', label: t('account'), group: 'account', show: true }, + { id: 'billing', href: '/settings/billing', label: t('billing'), group: 'account', show: true }, { id: 'company', href: '/settings/company', label: t('company'), group: 'company', show: hasCompany }, { id: 'bookkeeping', href: '/settings/bookkeeping', label: t('bookkeeping'), group: 'accounting', show: hasCompany }, { id: 'tax', href: '/settings/tax', label: t('tax'), group: 'accounting', show: hasCompany }, diff --git a/components/transactions/BankSyncNowButton.tsx b/components/transactions/BankSyncNowButton.tsx index 412a5c37..2f032c57 100644 --- a/components/transactions/BankSyncNowButton.tsx +++ b/components/transactions/BankSyncNowButton.tsx @@ -14,7 +14,8 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { createClient } from '@/lib/supabase/client' -import { useCompany } from '@/contexts/CompanyContext' +import { useCompany, useCapability } from '@/contexts/CompanyContext' +import { CAPABILITY } from '@/lib/entitlements/keys' interface BankConn { id: string @@ -38,6 +39,7 @@ export default function BankSyncNowButton() { const { toast } = useToast() const router = useRouter() const { company } = useCompany() + const hasBankSync = useCapability(CAPABILITY.bank_sync) const [connections, setConnections] = useState(null) const [busyId, setBusyId] = useState(null) @@ -148,35 +150,54 @@ export default function BankSyncNowButton() { const isBusy = busyId !== null const syncLabel = isBusy ? t('bank_sync_button_syncing') : t('bank_sync_button_now') + // Bank sync (and reconnect) is a paid external PSD2 call. Without the + // capability we keep the button VISIBLE as the conversion surface but inert, + // and surface an Uppgradera link. CSV/SIE import stays free (separate UI). + const gateTitle = !hasBankSync ? 'Bankkoppling kräver ett abonnemang' : undefined + const upsellNote = !hasBankSync ? ( + + Kräver abonnemang.{' '} + + Uppgradera + + + ) : null + if (connections.length === 1) { const conn = connections[0] const needsReconnect = conn.status !== 'active' return ( - +
+ + {upsellNote} +
) } return ( +
) } diff --git a/contexts/CompanyContext.tsx b/contexts/CompanyContext.tsx index ed589862..3fd9d3c9 100644 --- a/contexts/CompanyContext.tsx +++ b/contexts/CompanyContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext } from 'react' import type { Company, CompanyRole, Team } from '@/types' +import type { CapabilityKey } from '@/lib/entitlements/keys' interface CompanyContextValue { company: Company | null @@ -10,6 +11,8 @@ interface CompanyContextValue { isTeamMember: boolean team: Team | null isSandbox: boolean + /** PAID capability keys the active company currently holds (entitled + enabled). */ + capabilities: CapabilityKey[] } const CompanyContext = createContext(null) @@ -33,3 +36,15 @@ export function useCompany() { export function useCompanyOptional() { return useContext(CompanyContext) } + +/** + * Whether the active company holds a given paid capability. Controls UI + * affordances only — the server gate (lib/entitlements) is the real enforcement. + * Fail-open when rendered outside a CompanyProvider (e.g. standalone dialogs): + * the server still blocks; this only decides whether to show/disable/upsell. + */ +export function useCapability(key: CapabilityKey): boolean { + const ctx = useContext(CompanyContext) + if (!ctx) return true + return ctx.capabilities.includes(key) +} diff --git a/extensions/general/document-extraction/index.ts b/extensions/general/document-extraction/index.ts index 546648ac..37d6ea14 100644 --- a/extensions/general/document-extraction/index.ts +++ b/extensions/general/document-extraction/index.ts @@ -1,6 +1,8 @@ import type { Extension } from '@/lib/extensions/types' import type { SupabaseClient } from '@supabase/supabase-js' import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields' +import { hasCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import { createLogger } from '@/lib/logger' import { createServiceClient } from '@/lib/supabase/server' import type { DocumentAttachment } from '@/types' @@ -142,6 +144,11 @@ async function extractAndPersist( } const buffer = Buffer.from(await blob.arrayBuffer()) + if (!(await hasCapability(supabase, companyId, CAPABILITY.ai))) { + log.info('extraction skipped — ai capability not entitled', { doc: document.id, companyId }) + return + } + try { const { data, rawText } = await extractInvoiceFields({ buffer, diff --git a/lib/entitlements/__tests__/capability-grants.pg.test.ts b/lib/entitlements/__tests__/capability-grants.pg.test.ts new file mode 100644 index 00000000..17f40e8c --- /dev/null +++ b/lib/entitlements/__tests__/capability-grants.pg.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect } from 'vitest' +import { randomUUID } from 'node:crypto' +import { getPool, withUserContext } from '../../../tests/pg/setup' +import { seedCompany, insertAuthUser, insertCompany } from '../../../tests/pg/fixtures' + +// pg-real coverage for migrations 20260628140000 (capability_grants / +// company_capability_config / metered_events + company_has_capability RPC + +// RLS) and 20260629120000 (trial-grant trigger). Required by +// .claude/rules/database.md for any RPC/RLS/trigger change. +// +// NOTE: as of 20260629120000 an AFTER INSERT trigger auto-seeds a trial grant +// on the PAID keys for every new company. The resolver tests therefore call +// clearGrants() first to assert against a controlled grant state. + +const future = () => new Date(Date.now() + 86_400_000).toISOString() +const past = () => new Date(Date.now() - 86_400_000).toISOString() + +async function insertGrant(p: { + companyId?: string | null + teamId?: string | null + key: string + source?: string + expiresAt?: string | null +}): Promise { + await getPool().query( + `INSERT INTO public.capability_grants (company_id, team_id, capability_key, source, expires_at) + VALUES ($1, $2, $3, $4, $5)`, + [p.companyId ?? null, p.teamId ?? null, p.key, p.source ?? 'manual', p.expiresAt ?? null], + ) +} + +// Remove the trigger-seeded trial grants so a test can assert a controlled state. +async function clearGrants(companyId: string): Promise { + await getPool().query(`DELETE FROM public.capability_grants WHERE company_id = $1`, [companyId]) +} + +async function rpc(companyId: string, key: string): Promise { + const { rows } = await getPool().query<{ ok: boolean }>( + `SELECT public.company_has_capability($1, $2) AS ok`, + [companyId, key], + ) + return rows[0].ok +} + +describe('company_has_capability (entitlement axis)', () => { + it('is false when no grant exists (fail-closed)', async () => { + const { companyId } = await seedCompany() + await clearGrants(companyId) + expect(await rpc(companyId, 'ai')).toBe(false) + }) + + it('is true for an unexpired company-scoped grant', async () => { + const { companyId } = await seedCompany() + await clearGrants(companyId) + await insertGrant({ companyId, key: 'ai', expiresAt: future() }) + expect(await rpc(companyId, 'ai')).toBe(true) + }) + + it('treats a null expiry as never-expiring', async () => { + const { companyId } = await seedCompany() + await clearGrants(companyId) + await insertGrant({ companyId, key: 'ai', source: 'comp', expiresAt: null }) + expect(await rpc(companyId, 'ai')).toBe(true) + }) + + it('is false once the grant has expired', async () => { + const { companyId } = await seedCompany() + await clearGrants(companyId) + await insertGrant({ companyId, key: 'ai', source: 'trial', expiresAt: past() }) + expect(await rpc(companyId, 'ai')).toBe(false) + }) + + it('cascades a firm/team-scoped grant to the client company', async () => { + const { userId, companyId } = await seedCompany() + await clearGrants(companyId) + const teamId = randomUUID() + await getPool().query( + `INSERT INTO public.teams (id, name, created_by) VALUES ($1, 'Firm', $2)`, + [teamId, userId], + ) + await getPool().query(`UPDATE public.companies SET team_id = $1 WHERE id = $2`, [ + teamId, + companyId, + ]) + await insertGrant({ teamId, key: 'skatteverket', expiresAt: future() }) + expect(await rpc(companyId, 'skatteverket')).toBe(true) + }) +}) + +describe('company_has_capability (enablement axis)', () => { + it('is false when entitled but explicitly disabled', async () => { + const { companyId } = await seedCompany() + await clearGrants(companyId) + await insertGrant({ companyId, key: 'ai', expiresAt: null }) + await getPool().query( + `INSERT INTO public.company_capability_config (company_id, capability_key, enabled) + VALUES ($1, 'ai', false)`, + [companyId], + ) + expect(await rpc(companyId, 'ai')).toBe(false) + }) +}) + +describe('company_has_capability tenant guard', () => { + it('raises 42501 when a non-member asks about a company (authenticated ctx)', async () => { + const { companyId } = await seedCompany() + const outsider = await insertAuthUser() + await expect( + withUserContext(outsider, async (client) => { + await client.query(`SELECT public.company_has_capability($1, 'ai')`, [companyId]) + }), + ).rejects.toThrow(/unauthorized/) + }) + + it('lets a member resolve their own company under authenticated ctx', async () => { + const { userId, companyId } = await seedCompany() + const ok = await withUserContext(userId, async (client) => { + const r = await client.query<{ ok: boolean }>( + `SELECT public.company_has_capability($1, 'ai') AS ok`, + [companyId], + ) + return r.rows[0].ok + }) + // entitled via the auto-seeded trial grant + expect(ok).toBe(true) + }) +}) + +describe('capability_grants RLS', () => { + it('lets a member read their own grants but hides them from non-members', async () => { + const { userId, companyId } = await seedCompany() + await clearGrants(companyId) + await insertGrant({ companyId, key: 'ai', expiresAt: null }) + + const memberCount = await withUserContext(userId, async (client) => { + const r = await client.query( + `SELECT id FROM public.capability_grants WHERE company_id = $1`, + [companyId], + ) + return r.rowCount + }) + expect(memberCount).toBe(1) + + const outsider = await insertAuthUser() + const outsiderCount = await withUserContext(outsider, async (client) => { + const r = await client.query( + `SELECT id FROM public.capability_grants WHERE company_id = $1`, + [companyId], + ) + return r.rowCount + }) + expect(outsiderCount).toBe(0) + }) + + it('forbids a member from self-granting an entitlement (no INSERT policy)', async () => { + const { userId, companyId } = await seedCompany() + await expect( + withUserContext(userId, async (client) => { + await client.query( + `INSERT INTO public.capability_grants (company_id, capability_key, source) + VALUES ($1, 'ai', 'manual')`, + [companyId], + ) + }), + ).rejects.toThrow() + }) +}) + +describe('capability_grants scope constraint', () => { + it('rejects a grant with neither company_id nor team_id', async () => { + await expect( + getPool().query( + `INSERT INTO public.capability_grants (capability_key, source) VALUES ('ai', 'manual')`, + ), + ).rejects.toThrow() + }) + + it('rejects a grant with both company_id and team_id', async () => { + const { userId, companyId } = await seedCompany() + const teamId = randomUUID() + await getPool().query( + `INSERT INTO public.teams (id, name, created_by) VALUES ($1, 'Firm', $2)`, + [teamId, userId], + ) + await expect(insertGrant({ companyId, teamId, key: 'ai' })).rejects.toThrow() + }) +}) + +describe('trial grant seeding trigger (20260629120000)', () => { + it('grants a new company a 30-day trial on the PAID keys at creation', async () => { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId }) + const { rows } = await getPool().query<{ + capability_key: string + source: string + expires_at: string | null + }>( + `SELECT capability_key, source, expires_at FROM public.capability_grants + WHERE company_id = $1 ORDER BY capability_key`, + [companyId], + ) + expect(rows.map((r) => r.capability_key)).toEqual(['ai', 'bank_sync', 'email_send', 'skatteverket']) + expect(rows.every((r) => r.source === 'trial')).toBe(true) + expect(rows.every((r) => r.expires_at !== null)).toBe(true) + expect(await rpc(companyId, 'ai')).toBe(true) + }) + + it('does not seed free keys (only the PAID set is granted)', async () => { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId }) + expect(await rpc(companyId, 'cloud_backup')).toBe(false) + expect(await rpc(companyId, 'org_lookup')).toBe(false) + }) +}) diff --git a/lib/entitlements/__tests__/has-capability.test.ts b/lib/entitlements/__tests__/has-capability.test.ts new file mode 100644 index 00000000..d8a3de51 --- /dev/null +++ b/lib/entitlements/__tests__/has-capability.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { + hasCapability, + requireCapability, + capabilityBlockedResponse, +} from '../has-capability' +import { CAPABILITY } from '../keys' + +/** + * Per-table mock: each table resolves to its own configured result, so a + * function that queries several tables in one call (companies → capability_grants + * → company_capability_config) gets the right answer per table. Any chained + * method returns the chain; awaiting it (or .maybeSingle()/.or()) resolves to + * the table's result. + */ +type TableResult = { data: unknown; error?: unknown } +function makeSupabase(byTable: Record): SupabaseClient { + const chainFor = (table: string) => { + const result = byTable[table] ?? { data: null, error: null } + const chain: unknown = new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null }) + } + return () => chain + }, + }, + ) + return chain + } + return { from: (t: string) => chainFor(t) } as unknown as SupabaseClient +} + +const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString() + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('hasCapability', () => { + it('returns true on self-hosted without touching the DB', async () => { + vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true') + const supabase = makeSupabase({}) // would resolve to null/false if queried + expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(true) + }) + + it('returns true for an unexpired company-scoped grant', async () => { + const supabase = makeSupabase({ + companies: { data: { team_id: null } }, + capability_grants: { data: [{ expires_at: iso(60_000) }] }, + company_capability_config: { data: null }, + }) + expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(true) + }) + + it('treats a null expiry as never-expiring (true)', async () => { + const supabase = makeSupabase({ + companies: { data: { team_id: null } }, + capability_grants: { data: [{ expires_at: null }] }, + company_capability_config: { data: null }, + }) + expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.bank_sync)).toBe(true) + }) + + it('fails closed when there is no grant', async () => { + const supabase = makeSupabase({ + companies: { data: { team_id: null } }, + capability_grants: { data: [] }, + }) + expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(false) + }) + + it('fails closed when the only grant is expired', async () => { + const supabase = makeSupabase({ + companies: { data: { team_id: null } }, + capability_grants: { data: [{ expires_at: iso(-60_000) }] }, + }) + expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(false) + }) + + it('honours a firm/team-scoped grant (cascades to the client company)', async () => { + const supabase = makeSupabase({ + companies: { data: { team_id: '22222222-2222-4222-8222-222222222222' } }, + capability_grants: { data: [{ expires_at: iso(60_000) }] }, // grant lives on the team + company_capability_config: { data: null }, + }) + expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.skatteverket)).toBe(true) + }) + + it('returns false when entitled but explicitly disabled (enablement axis)', async () => { + const supabase = makeSupabase({ + companies: { data: { team_id: null } }, + capability_grants: { data: [{ expires_at: null }] }, + company_capability_config: { data: { enabled: false } }, + }) + expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(false) + }) + + it('fails closed when the grants query errors', async () => { + const supabase = makeSupabase({ + companies: { data: { team_id: null } }, + capability_grants: { data: null, error: { message: 'boom' } }, + }) + expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(false) + }) +}) + +describe('requireCapability', () => { + it('returns null (proceed) when the company has the capability', async () => { + const supabase = makeSupabase({ + companies: { data: { team_id: null } }, + capability_grants: { data: [{ expires_at: null }] }, + company_capability_config: { data: null }, + }) + expect(await requireCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBeNull() + }) + + it('returns a 403 capability_blocked response when missing', async () => { + const supabase = makeSupabase({ + companies: { data: { team_id: null } }, + capability_grants: { data: [] }, + }) + const res = await requireCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai) + expect(res).not.toBeNull() + expect(res!.status).toBe(403) + const body = await res!.json() + expect(body.capability_blocked).toBe(true) + expect(body.capability).toBe(CAPABILITY.ai) + }) +}) + +describe('capabilityBlockedResponse', () => { + it('returns a bilingual 403 carrying the capability key', async () => { + const res = capabilityBlockedResponse(CAPABILITY.bank_sync) + expect(res.status).toBe(403) + const body = await res.json() + expect(body.error).toBeTruthy() + expect(body.error_en).toBeTruthy() + expect(body.capability_blocked).toBe(true) + expect(body.capability).toBe(CAPABILITY.bank_sync) + }) +}) diff --git a/lib/entitlements/has-capability.ts b/lib/entitlements/has-capability.ts new file mode 100644 index 00000000..b980de26 --- /dev/null +++ b/lib/entitlements/has-capability.ts @@ -0,0 +1,168 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { NextResponse } from 'next/server' +import { PAID_CAPABILITIES, type CapabilityKey } from './keys' + +/** + * Entitlement gate — the single primitive behind the paywall ("non-payer loses + * functionality") AND the vision's modularity-out ("hide a module this company + * doesn't need"). Both are the same question: does this company hold the + * capability, fail-closed, resolved server-side? + * + * Two orthogonal axes, AND-ed together (see migration + * 20260628140000_capability_grants_and_metered_events): + * ENTITLEMENT — an unexpired capability_grant on the company OR its firm/team. + * ENABLEMENT — not explicitly disabled in company_capability_config (absent == enabled). + * + * Mirrors the shape of lib/sandbox/guard.ts so it drops in at the same call + * sites. The company is resolved by the CALLER (requireCompanyId for web, the + * validated API key for MCP) — never taken from untrusted input here. + */ + +/** Self-hosted deployments are all-on — the gate never withholds anything. */ +function isSelfHosted(): boolean { + return process.env.NEXT_PUBLIC_SELF_HOSTED === 'true' +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +/** + * Only server-resolved UUIDs may be interpolated into the PostgREST `.or()` + * filter below — commas/dots/parens are filter syntax. companyId/teamId always + * come from the DB, but we validate at this boundary as defense in depth. + */ +function isUuid(v: string): boolean { + return UUID_RE.test(v) +} + +export async function hasCapability( + supabase: SupabaseClient, + companyId: string, + key: CapabilityKey, +): Promise { + if (isSelfHosted()) return true + if (!isUuid(companyId)) return false // fail-closed: never interpolate a non-UUID + + // Resolve the company's firm/team (firm-scoped grants cascade to clients). + const { data: company } = await supabase + .from('companies') + .select('team_id') + .eq('id', companyId) + .maybeSingle() + const rawTeamId = (company as { team_id: string | null } | null)?.team_id ?? null + const teamId = rawTeamId && isUuid(rawTeamId) ? rawTeamId : null + + // ENTITLEMENT axis: any unexpired grant on the company or its team. + const scopeFilter = teamId + ? `company_id.eq.${companyId},team_id.eq.${teamId}` + : `company_id.eq.${companyId}` + const { data: grants, error: grantsError } = await supabase + .from('capability_grants') + .select('expires_at') + .eq('capability_key', key) + .or(scopeFilter) + + if (grantsError) return false // fail-closed on any read error + const now = Date.now() + const entitled = (grants ?? []).some((g) => { + const exp = (g as { expires_at: string | null }).expires_at + return exp === null || new Date(exp).getTime() > now + }) + if (!entitled) return false + + // ENABLEMENT axis: explicitly turned off for this company? (absence == enabled) + const { data: config } = await supabase + .from('company_capability_config') + .select('enabled') + .eq('company_id', companyId) + .eq('capability_key', key) + .maybeSingle() + if ((config as { enabled: boolean } | null)?.enabled === false) return false + + return true +} + +/** + * Standard bilingual 403 for a capability-blocked endpoint. Matches the + * sandbox/guard envelope so the UI surfaces the upsell consistently. + */ +export function capabilityBlockedResponse(key: CapabilityKey): NextResponse { + return NextResponse.json( + { + error: + 'Den här funktionen kräver en betald prenumeration. Uppgradera för att fortsätta använda externa tjänster.', + error_en: + 'This feature requires a paid subscription. Upgrade to keep using external services.', + capability_blocked: true, + capability: key, + }, + { status: 403 }, + ) +} + +/** + * Convenience wrapper: check + return the 403 in one call. Returns the + * NextResponse to return from the route, or null when the company has the + * capability and the route should proceed. + * + * const blocked = await requireCapability(supabase, companyId, CAPABILITY.ai) + * if (blocked) return blocked + */ +export async function requireCapability( + supabase: SupabaseClient, + companyId: string, + key: CapabilityKey, +): Promise { + if (await hasCapability(supabase, companyId, key)) return null + return capabilityBlockedResponse(key) +} + +/** + * Resolve which PAID capabilities a company currently holds (entitled AND + * enabled), in two queries. Used to seed the client CompanyContext so the UI + * can hide/disable/upsell gated features. Self-hosted holds everything. + */ +export async function getCompanyCapabilities( + supabase: SupabaseClient, + companyId: string, +): Promise { + if (isSelfHosted()) return [...PAID_CAPABILITIES] + if (!isUuid(companyId)) return [] // fail-closed: never interpolate a non-UUID + + const { data: company } = await supabase + .from('companies') + .select('team_id') + .eq('id', companyId) + .maybeSingle() + const rawTeamId = (company as { team_id: string | null } | null)?.team_id ?? null + const teamId = rawTeamId && isUuid(rawTeamId) ? rawTeamId : null + + const scopeFilter = teamId + ? `company_id.eq.${companyId},team_id.eq.${teamId}` + : `company_id.eq.${companyId}` + const { data: grants } = await supabase + .from('capability_grants') + .select('capability_key, expires_at') + .in('capability_key', PAID_CAPABILITIES as unknown as string[]) + .or(scopeFilter) + + const now = Date.now() + const entitled = new Set() + for (const g of grants ?? []) { + const row = g as { capability_key: string; expires_at: string | null } + if (row.expires_at === null || new Date(row.expires_at).getTime() > now) { + entitled.add(row.capability_key) + } + } + if (entitled.size === 0) return [] + + // Subtract any explicitly-disabled (enablement axis). + const { data: configs } = await supabase + .from('company_capability_config') + .select('capability_key, enabled') + .eq('company_id', companyId) + .eq('enabled', false) + for (const c of configs ?? []) { + entitled.delete((c as { capability_key: string }).capability_key) + } + + return PAID_CAPABILITIES.filter((k) => entitled.has(k)) +} diff --git a/lib/entitlements/keys.ts b/lib/entitlements/keys.ts new file mode 100644 index 00000000..d4fd0c03 --- /dev/null +++ b/lib/entitlements/keys.ts @@ -0,0 +1,55 @@ +/** + * Capability keys — the single namespace behind the SaaS paywall AND the + * per-tenant modularity / marketplace vision. Each key names one gateable + * feature; a company "has" it when an unexpired capability_grant exists + * (entitlement) and it isn't explicitly disabled (enablement). + * + * These keys are a STABLE CONTRACT: grant rows, the future marketplace catalog, + * and per-tenant module toggles all reference them. Add keys; never rename one. + */ +export const CAPABILITY = { + /** AI assistant chat, onboarding composer, and document field extraction (Anthropic/Bedrock). */ + ai: 'ai', + /** Bank sync / PSD2 (Enable Banking). Freeze-and-retain: tokens are NOT revoked on downgrade. */ + bank_sync: 'bank_sync', + /** Skatteverket filing/sync — VAT, AGI, skattekonto — via BankID. */ + skatteverket: 'skatteverket', + /** Outbound transactional email: invoices, reminders, payslips (Resend). Auth/account email is never gated. */ + email_send: 'email_send', + /** Org-number lookup / enrichment (TIC). NOT gated — identity/lookup is always free. */ + org_lookup: 'org_lookup', + /** EU VAT-number validation (VIES). NOT gated — identity/lookup is always free. */ + vat_validation: 'vat_validation', + /** Riksbanken FX auto-fetch. NOT gated at launch (kept free); manual rate entry is always allowed. */ + currency_rates: 'currency_rates', + /** Cloud backup to Google Drive. NOT gated at launch (kept free — never hold a customer's data hostage). */ + cloud_backup: 'cloud_backup', + /** Migration import from other systems (Fortnox/Visma/Bokio/BL/Briox). Kept open so new payers can migrate IN. */ + migration: 'migration', + /** Bolagsverket iXBRL årsredovisning filing. Reserved (extension not yet enabled). */ + bolagsverket: 'bolagsverket', +} as const + +export type CapabilityKey = (typeof CAPABILITY)[keyof typeof CAPABILITY] + +/** + * The set actually withheld from non-payers (manual tier) at the 2026-07-07 + * cutover. Founder decision (2026-06-28): gate the high-value recurring external + * services only. + * + * KEPT FREE on purpose: + * - identity & lookup: TIC org_lookup, VIES vat_validation, BankID login — + * they aid onboarding/data quality; gating them is friction in the wrong place. + * - currency_rates (FX auto-fetch) and cloud_backup. + * Internal bookkeeping is always fully usable on the manual tier. + * + * NOTE: bank_sync and skatteverket stay PAID even though their flows use BankID + * as an auth step — what's charged for is the bank data sync and the VAT/AGI + * filing service, not the identity check. + */ +export const PAID_CAPABILITIES: readonly CapabilityKey[] = [ + CAPABILITY.ai, + CAPABILITY.bank_sync, + CAPABILITY.skatteverket, + CAPABILITY.email_send, +] as const diff --git a/lib/entitlements/metering.ts b/lib/entitlements/metering.ts new file mode 100644 index 00000000..b2483069 --- /dev/null +++ b/lib/entitlements/metering.ts @@ -0,0 +1,33 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { CapabilityKey } from './keys' + +/** + * Append a usage event to metered_events. Best-effort and non-blocking: + * metering must never break the feature it measures, so failures are swallowed. + * + * Usage cannot be backfilled, so we capture it from day one even though no + * usage-based pricing exists yet — it is the raw material for future firm-level + * "active company" / consumption billing. + */ +export async function recordMeteredEvent( + supabase: SupabaseClient, + params: { + companyId: string + teamId?: string | null + key: CapabilityKey + eventType: string + attribution?: Record + }, +): Promise { + try { + await supabase.from('metered_events').insert({ + company_id: params.companyId, + team_id: params.teamId ?? null, + capability_key: params.key, + event_type: params.eventType, + attribution: params.attribution ?? {}, + }) + } catch { + // best-effort; never block the metered operation + } +} diff --git a/lib/stripe/__tests__/subscription-sync.test.ts b/lib/stripe/__tests__/subscription-sync.test.ts new file mode 100644 index 00000000..e0ea41b9 --- /dev/null +++ b/lib/stripe/__tests__/subscription-sync.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import type Stripe from 'stripe' +import { + statusGrantsAccess, + subscriptionToState, + applySubscriptionState, +} from '../subscription-sync' + +afterEach(() => vi.unstubAllEnvs()) + +// Recording mock: captures the from()/upsert()/delete()/eq() operations so we +// can assert what applySubscriptionState wrote, without a real DB. +interface RecordedOp { + table: string + op: 'upsert' | 'delete' | null + payload: unknown + conflict: string | undefined + filters: Array<[string, unknown]> +} +function recordingSupabase() { + const calls: RecordedOp[] = [] + const supabase = { + from(table: string) { + const ctx: RecordedOp = { table, op: null, payload: null, conflict: undefined, filters: [] } + const chain = { + upsert(payload: unknown, opts?: { onConflict?: string }) { + ctx.op = 'upsert' + ctx.payload = payload + ctx.conflict = opts?.onConflict + calls.push(ctx) + return chain + }, + delete() { + ctx.op = 'delete' + calls.push(ctx) + return chain + }, + eq(col: string, val: unknown) { + ctx.filters.push([col, val]) + return chain + }, + then(resolve: (v: { data: null; error: null }) => void) { + resolve({ data: null, error: null }) + }, + } + return chain + }, + } + return { supabase: supabase as unknown as SupabaseClient, calls } +} + +function fakeSub(over: Partial<{ status: string; priceId: string; interval: string; periodEnd: number; customer: string }> = {}): Stripe.Subscription { + return { + id: 'sub_123', + customer: over.customer ?? 'cus_123', + status: over.status ?? 'active', + metadata: {}, + items: { + data: [ + { + price: { id: over.priceId ?? 'price_x', recurring: { interval: over.interval ?? 'month' } }, + current_period_end: over.periodEnd ?? Math.floor(Date.now() / 1000) + 30 * 86400, + }, + ], + }, + } as unknown as Stripe.Subscription +} + +describe('statusGrantsAccess', () => { + it('grants for active/trialing/past_due, denies otherwise', () => { + expect(statusGrantsAccess('active')).toBe(true) + expect(statusGrantsAccess('trialing')).toBe(true) + expect(statusGrantsAccess('past_due')).toBe(true) + expect(statusGrantsAccess('canceled')).toBe(false) + expect(statusGrantsAccess('unpaid')).toBe(false) + expect(statusGrantsAccess(null)).toBe(false) + }) +}) + +describe('subscriptionToState', () => { + it('maps status, customer, id, and period end', () => { + const end = Math.floor(Date.now() / 1000) + 1000 + const state = subscriptionToState(fakeSub({ status: 'active', periodEnd: end }), 'co_1') + expect(state.companyId).toBe('co_1') + expect(state.stripeCustomerId).toBe('cus_123') + expect(state.stripeSubscriptionId).toBe('sub_123') + expect(state.status).toBe('active') + expect(state.currentPeriodEnd).toBe(new Date(end * 1000).toISOString()) + }) + + it('derives plan from the env price id, falling back to interval', () => { + vi.stubEnv('STRIPE_PRICE_YEARLY', 'price_year') + vi.stubEnv('STRIPE_PRICE_MONTHLY', 'price_month') + expect(subscriptionToState(fakeSub({ priceId: 'price_year' }), 'co').plan).toBe('yearly') + expect(subscriptionToState(fakeSub({ priceId: 'price_month' }), 'co').plan).toBe('monthly') + // unknown price id -> interval fallback + expect(subscriptionToState(fakeSub({ priceId: 'price_other', interval: 'year' }), 'co').plan).toBe('yearly') + }) +}) + +describe('applySubscriptionState', () => { + it('grants the PAID keys when the subscription is active', async () => { + const { supabase, calls } = recordingSupabase() + await applySubscriptionState(supabase, { + companyId: 'co_1', + stripeCustomerId: 'cus_1', + stripeSubscriptionId: 'sub_1', + status: 'active', + plan: 'yearly', + currentPeriodEnd: new Date().toISOString(), + }) + const subUpsert = calls.find((c) => c.table === 'company_subscriptions') + expect(subUpsert?.op).toBe('upsert') + const grantUpsert = calls.find((c) => c.table === 'capability_grants') + expect(grantUpsert?.op).toBe('upsert') + const rows = grantUpsert?.payload as Array<{ capability_key: string; source: string }> + expect(rows.map((r) => r.capability_key).sort()).toEqual(['ai', 'bank_sync', 'email_send', 'skatteverket']) + expect(rows.every((r) => r.source === 'stripe')).toBe(true) + }) + + it('removes only the stripe grants when canceled (freeze-and-retain)', async () => { + const { supabase, calls } = recordingSupabase() + await applySubscriptionState(supabase, { + companyId: 'co_1', + stripeCustomerId: 'cus_1', + stripeSubscriptionId: 'sub_1', + status: 'canceled', + plan: null, + currentPeriodEnd: null, + }) + const grantOp = calls.find((c) => c.table === 'capability_grants') + expect(grantOp?.op).toBe('delete') + expect(grantOp?.filters).toContainEqual(['company_id', 'co_1']) + expect(grantOp?.filters).toContainEqual(['source', 'stripe']) + }) +}) diff --git a/lib/stripe/client.ts b/lib/stripe/client.ts new file mode 100644 index 00000000..22f267f4 --- /dev/null +++ b/lib/stripe/client.ts @@ -0,0 +1,31 @@ +import Stripe from 'stripe' + +let cached: Stripe | null = null + +/** + * Singleton Stripe client. Throws if STRIPE_SECRET_KEY is unset so misconfig + * fails loudly at call time rather than silently no-opping. The API version is + * pinned by the installed SDK (stripe@22), which is the recommended stable + * default — do not hardcode a version string that can drift from the SDK types. + */ +export function getStripe(): Stripe { + if (cached) return cached + const key = process.env.STRIPE_SECRET_KEY + if (!key) throw new Error('STRIPE_SECRET_KEY is not configured') + cached = new Stripe(key) + return cached +} + +export type BillingPlan = 'monthly' | 'yearly' + +/** Stripe Price id for a plan, from env. Returns undefined if not configured. */ +export function priceIdForPlan(plan: BillingPlan): string | undefined { + return plan === 'yearly' + ? process.env.STRIPE_PRICE_YEARLY + : process.env.STRIPE_PRICE_MONTHLY +} + +/** Whether Stripe checkout is configured (used to gate the upgrade CTA). */ +export function isStripeConfigured(): boolean { + return Boolean(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PRICE_MONTHLY) +} diff --git a/lib/stripe/subscription-sync.ts b/lib/stripe/subscription-sync.ts new file mode 100644 index 00000000..9594f56c --- /dev/null +++ b/lib/stripe/subscription-sync.ts @@ -0,0 +1,164 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type Stripe from 'stripe' +import { PAID_CAPABILITIES } from '@/lib/entitlements/keys' +import type { BillingPlan } from './client' + +/** + * Stripe → DB reconciliation for subscriptions. The single source of truth for + * paid access is capability_grants(source='stripe'); this module writes those + * grants from subscription state and removes them on cancellation + * (freeze-and-retain — only the stripe grants are touched, never bank tokens or + * AI data). + */ + +// Statuses that should keep paid access on. past_due stays on as a grace window +// (Stripe's dunning retries the charge); access is revoked only once the +// subscription is genuinely canceled/unpaid. +const ACCESS_STATUSES = new Set(['active', 'trialing', 'past_due']) + +export function statusGrantsAccess(status: string | null | undefined): boolean { + return !!status && ACCESS_STATUSES.has(status) +} + +export interface SubscriptionState { + companyId: string + stripeCustomerId: string | null + stripeSubscriptionId: string | null + status: string | null + plan: BillingPlan | null + currentPeriodEnd: string | null // ISO +} + +function planFromSubscription(sub: Stripe.Subscription): BillingPlan | null { + const price = sub.items.data[0]?.price + const priceId = price?.id + if (priceId && priceId === process.env.STRIPE_PRICE_YEARLY) return 'yearly' + if (priceId && priceId === process.env.STRIPE_PRICE_MONTHLY) return 'monthly' + // Fallback by recurring interval if env price ids aren't wired up. + const interval = price?.recurring?.interval + if (interval === 'year') return 'yearly' + if (interval === 'month') return 'monthly' + return null +} + +// current_period_end lives on the Subscription in older API versions and on the +// subscription item in newer ones — read defensively so SDK/API drift can't +// break the build or the expiry calc. +function periodEndIso(sub: Stripe.Subscription): string | null { + const s = sub as unknown as { + current_period_end?: number + items?: { data?: Array<{ current_period_end?: number }> } + } + const unix = s.current_period_end ?? s.items?.data?.[0]?.current_period_end ?? null + return unix ? new Date(unix * 1000).toISOString() : null +} + +export function subscriptionToState(sub: Stripe.Subscription, companyId: string): SubscriptionState { + return { + companyId, + stripeCustomerId: typeof sub.customer === 'string' ? sub.customer : (sub.customer?.id ?? null), + stripeSubscriptionId: sub.id, + status: sub.status, + plan: planFromSubscription(sub), + currentPeriodEnd: periodEndIso(sub), + } +} + +/** + * Reconcile a company's subscription state into the DB: upsert + * company_subscriptions, then either grant or remove the stripe capability + * grants. Idempotent — safe to run on duplicate/retried events. + */ +export async function applySubscriptionState( + supabase: SupabaseClient, + state: SubscriptionState, +): Promise { + await supabase.from('company_subscriptions').upsert( + { + company_id: state.companyId, + stripe_customer_id: state.stripeCustomerId, + stripe_subscription_id: state.stripeSubscriptionId, + status: state.status, + plan: state.plan, + current_period_end: state.currentPeriodEnd, + updated_at: new Date().toISOString(), + }, + { onConflict: 'company_id' }, + ) + + if (statusGrantsAccess(state.status)) { + // Grant a few days past the period end so a brief renewal-webhook delay + // never flips a paying customer to blocked. + const expiresAt = state.currentPeriodEnd + ? new Date(new Date(state.currentPeriodEnd).getTime() + 3 * 24 * 3600 * 1000).toISOString() + : null + const rows = PAID_CAPABILITIES.map((key) => ({ + company_id: state.companyId, + capability_key: key, + source: 'stripe', + expires_at: expiresAt, + })) + await supabase + .from('capability_grants') + .upsert(rows, { onConflict: 'company_id,team_id,capability_key,source' }) + } else { + // Freeze-and-retain: drop only the stripe grants. Trial/comp grants (if any) + // are untouched; data and tokens are never deleted. + await supabase + .from('capability_grants') + .delete() + .eq('company_id', state.companyId) + .eq('source', 'stripe') + } +} + +async function companyIdForCustomer( + supabase: SupabaseClient, + customerId: string, +): Promise { + const { data } = await supabase + .from('company_subscriptions') + .select('company_id') + .eq('stripe_customer_id', customerId) + .maybeSingle() + return (data as { company_id: string } | null)?.company_id ?? null +} + +/** + * Route a verified Stripe event to a state reconciliation. Only subscription + * lifecycle events matter; everything else is a no-op (already ack'd 200). + */ +export async function handleStripeEvent( + supabase: SupabaseClient, + stripe: Stripe, + event: Stripe.Event, +): Promise { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session + if (session.mode !== 'subscription' || !session.subscription) return + const sub = await stripe.subscriptions.retrieve(session.subscription as string) + const companyId = + session.metadata?.company_id ?? + session.client_reference_id ?? + sub.metadata?.company_id ?? + null + if (companyId) await applySubscriptionState(supabase, subscriptionToState(sub, companyId)) + return + } + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + const sub = event.data.object as Stripe.Subscription + const companyId = + sub.metadata?.company_id ?? + (await companyIdForCustomer(supabase, typeof sub.customer === 'string' ? sub.customer : sub.customer.id)) + if (companyId) await applySubscriptionState(supabase, subscriptionToState(sub, companyId)) + return + } + default: + // invoice.payment_failed etc. — Stripe also emits subscription.updated + // (-> past_due / canceled), which the cases above handle. No-op here. + return + } +} diff --git a/messages/en.json b/messages/en.json index ab906e3f..7f0bbe05 100644 --- a/messages/en.json +++ b/messages/en.json @@ -180,6 +180,7 @@ "backup": "Backup", "account": "Account", "api": "API", + "billing": "Subscription", "group_account": "Account", "group_company": "Company", "group_accounting": "Accounting & tax", diff --git a/messages/sv.json b/messages/sv.json index b4dc6352..b8f0d54e 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -180,6 +180,7 @@ "backup": "Säkerhetsbackup", "account": "Konto", "api": "API", + "billing": "Abonnemang", "group_account": "Konto", "group_company": "Företag", "group_accounting": "Bokföring & skatt", diff --git a/package-lock.json b/package-lock.json index 270df2a3..c6b224d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "resend": "^6.9.1", "server-only": "^0.0.1", "sharp": "^0.34.5", + "stripe": "^22.3.0", "svix": "^1.85.0", "tailwind-merge": "^3.4.0", "web-push": "^3.6.7", @@ -16298,6 +16299,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "22.3.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.3.0.tgz", + "integrity": "sha512-ypO6xjVrMWs9SmIMeHr8naCx3dAQ0clxMdUTxn7Ejd7hmY9meBGfE+N4pVHkf9sUNebAHp6uJo6mV3GxDIc2cA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strnum": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", diff --git a/package.json b/package.json index 18937253..0690f4c6 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "resend": "^6.9.1", "server-only": "^0.0.1", "sharp": "^0.34.5", + "stripe": "^22.3.0", "svix": "^1.85.0", "tailwind-merge": "^3.4.0", "web-push": "^3.6.7", diff --git a/supabase/migrations/20260628140000_capability_grants_and_metered_events.sql b/supabase/migrations/20260628140000_capability_grants_and_metered_events.sql new file mode 100644 index 00000000..024f0727 --- /dev/null +++ b/supabase/migrations/20260628140000_capability_grants_and_metered_events.sql @@ -0,0 +1,189 @@ +-- Capability/entitlement substrate — the single primitive behind the SaaS +-- paywall AND the vision's per-tenant modularity / marketplace. +-- +-- A company's access to a feature resolves over TWO orthogonal axes, kept in +-- two separate tables on purpose so they never fight: +-- * ENTITLEMENT (capability_grants) — "is the company allowed/paid for it?" written by billing. +-- * ENABLEMENT (company_capability_config) — "is it turned on?" written by the byrå/onboarding/admin. +-- hasCapability(company, key) == entitled(company,key) AND NOT explicitly-disabled(company,key). +-- +-- Why this deviates from the standard company-scoped table template: +-- * NO user_id: a grant is owned by a company (or a firm/team), not a user. +-- Billing/trial-seeding/admin write these via the service role; a normal +-- user must NEVER be able to self-grant an entitlement. +-- * SELECT-only RLS for authenticated; INSERT/UPDATE/DELETE are service-role +-- only (service_role bypasses RLS). Mirrors agent_atom_registry. +-- * POLYMORPHIC scope: company_id OR team_id (exactly one). team_id is the +-- already-nullable "firm" axis (companies.team_id) — so per-company billing +-- and firm-level billing ride the same table with zero schema churn later. +-- * NO write_audit_log trigger: company_id is nullable on team-scoped grants +-- (the generic audit trigger assumes a company_id); provenance lives in +-- source + metadata + granted_at here, and metered_events is the usage log. +-- +-- The compliance kernel stays OUTSIDE this system — no kernel capability is ever +-- a grantable/removable row. + +-- ============================================================================= +-- 1. capability_grants — ENTITLEMENT axis +-- ============================================================================= +CREATE TABLE public.capability_grants ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + company_id uuid REFERENCES public.companies(id) ON DELETE CASCADE, + team_id uuid REFERENCES public.teams(id) ON DELETE CASCADE, + capability_key text NOT NULL, + source text NOT NULL CHECK (source IN ('trial', 'stripe', 'manual', 'comp')), + granted_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz, -- NULL = never expires (comp / paid-in-good-standing) + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + -- Exactly one scope: a grant is either company-scoped or firm/team-scoped. + CONSTRAINT capability_grants_one_scope CHECK (num_nonnulls(company_id, team_id) = 1) +); + +-- One row per (scope-entity, capability, source) so billing can UPSERT (extend a +-- trial's expiry, refresh a stripe grant). NULLS NOT DISTINCT (PG15+) makes the +-- nullable scope column behave as a real key component. +CREATE UNIQUE INDEX capability_grants_scope_key_source_uniq + ON public.capability_grants (company_id, team_id, capability_key, source) NULLS NOT DISTINCT; + +CREATE INDEX idx_capability_grants_company_id ON public.capability_grants (company_id); +CREATE INDEX idx_capability_grants_team_id ON public.capability_grants (team_id); +CREATE INDEX idx_capability_grants_key ON public.capability_grants (capability_key); + +ALTER TABLE public.capability_grants ENABLE ROW LEVEL SECURITY; + +-- Read-only for members of the owning company OR firm/team. No write policy for +-- authenticated by design — grants are written only via the service role +-- (Stripe webhook, trial seeding, admin tooling), so a user can never grant +-- themselves an entitlement. +CREATE POLICY "members read capability_grants" + ON public.capability_grants FOR SELECT + USING ( + (company_id IS NOT NULL AND company_id IN (SELECT public.user_company_ids())) + OR (team_id IS NOT NULL AND team_id IN (SELECT public.user_team_ids())) + ); + +CREATE TRIGGER set_updated_at_capability_grants + BEFORE UPDATE ON public.capability_grants + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================================================= +-- 2. company_capability_config — ENABLEMENT axis +-- ============================================================================= +-- Absence of a row == enabled (when entitled). A row with enabled=false is the +-- "entitled but turned off" state (the future modularity-out toggle; no UI yet). +CREATE TABLE public.company_capability_config ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + capability_key text NOT NULL, + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT company_capability_config_uniq UNIQUE (company_id, capability_key) +); + +CREATE INDEX idx_company_capability_config_company_id ON public.company_capability_config (company_id); + +ALTER TABLE public.company_capability_config ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "members read company_capability_config" + ON public.company_capability_config FOR SELECT + USING (company_id IN (SELECT public.user_company_ids())); + +CREATE TRIGGER set_updated_at_company_capability_config + BEFORE UPDATE ON public.company_capability_config + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================================================= +-- 3. metered_events — append-only usage capture +-- ============================================================================= +-- Usage cannot be backfilled, so we capture it from day one even though no +-- usage-based pricing exists yet. team_id is denormalized for firm-level rollup. +CREATE TABLE public.metered_events ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + team_id uuid REFERENCES public.teams(id) ON DELETE SET NULL, + capability_key text NOT NULL, + event_type text NOT NULL, -- e.g. 'use', 'auto_commit', 'pack_install' + occurred_at timestamptz NOT NULL DEFAULT now(), + attribution jsonb NOT NULL DEFAULT '{}'::jsonb -- { actor_id, actor_kind, pack_id, ... } +); + +CREATE INDEX idx_metered_events_company_occurred ON public.metered_events (company_id, occurred_at DESC); +CREATE INDEX idx_metered_events_key ON public.metered_events (capability_key); + +ALTER TABLE public.metered_events ENABLE ROW LEVEL SECURITY; + +-- Members may read their own usage; rows are written only via the service role +-- (append-only — no UPDATE/DELETE policy for anyone). +CREATE POLICY "members read metered_events" + ON public.metered_events FOR SELECT + USING (company_id IN (SELECT public.user_company_ids())); + +-- ============================================================================= +-- 4. company_has_capability() — DB-side resolver (defense-in-depth / RLS reuse) +-- ============================================================================= +-- The PRIMARY gate is the TS hasCapability() (it must cover the MCP/API-key/cron +-- service-role paths, which bypass the JWT guard below by design). This RPC +-- mirrors that resolution in the DB so RLS policies and other RPCs can gate on a +-- capability, and it reuses the exact jwt-role + user_company_ids() tenant guard +-- shipped in 20260619130100. +CREATE OR REPLACE FUNCTION public.company_has_capability( + p_company_id uuid, + p_capability_key text +) +RETURNS boolean +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''); + v_team_id uuid; + v_entitled boolean; + v_disabled boolean; +BEGIN + -- Tenant guard: anon/authenticated may only ask about their own companies; + -- service_role / direct access (no JWT role — MCP/API-key/cron, migrations, + -- pg-real harness) bypasses BY DESIGN, with company scoping enforced in TS. + IF v_jwt_role IN ('anon', 'authenticated') + AND p_company_id NOT IN (SELECT public.user_company_ids()) THEN + RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id + USING ERRCODE = '42501'; + END IF; + + SELECT team_id INTO v_team_id FROM public.companies WHERE id = p_company_id; + + -- Entitlement axis: any non-expired grant on the company OR its firm/team. + SELECT EXISTS ( + SELECT 1 FROM public.capability_grants g + WHERE g.capability_key = p_capability_key + AND ( + g.company_id = p_company_id + OR (v_team_id IS NOT NULL AND g.team_id = v_team_id) + ) + AND (g.expires_at IS NULL OR g.expires_at > now()) + ) INTO v_entitled; + + IF NOT v_entitled THEN + RETURN false; -- fail-closed + END IF; + + -- Enablement axis: explicitly turned off for this company? (absence == enabled) + SELECT EXISTS ( + SELECT 1 FROM public.company_capability_config c + WHERE c.company_id = p_company_id + AND c.capability_key = p_capability_key + AND c.enabled = false + ) INTO v_disabled; + + RETURN NOT v_disabled; +END; +$$; + +REVOKE ALL ON FUNCTION public.company_has_capability(uuid, text) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.company_has_capability(uuid, text) TO authenticated, service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260629120000_seed_trial_and_comp_capability_grants.sql b/supabase/migrations/20260629120000_seed_trial_and_comp_capability_grants.sql new file mode 100644 index 00000000..ac15a95f --- /dev/null +++ b/supabase/migrations/20260629120000_seed_trial_and_comp_capability_grants.sql @@ -0,0 +1,85 @@ +-- Seed trial + comp capability grants so the fail-closed gate (migration +-- 20260628140000) can be deployed without locking anyone out. +-- +-- THREE parts: +-- 1. A trigger that grants every NEW company a 30-day trial on the PAID keys, +-- at creation, on EVERY creation path (RPC / MCP / direct insert). This is +-- what makes a new signup able to use onboarding AI immediately. +-- 2. A one-time backfill of trial grants for EXISTING companies, using the +-- cutover rule: created on/before 2026-06-07 -> trial ends 2026-07-07; +-- created after -> created_at + 30 days. +-- 3. Permanent comp grants (never expire) for the two comp companies. +-- +-- PAID keys mirror lib/entitlements/keys.ts PAID_CAPABILITIES +-- (ai, bank_sync, skatteverket, email_send). Free keys (org_lookup, +-- vat_validation, currency_rates, cloud_backup) are intentionally NOT seeded. +-- Grants are company-scoped (team_id NULL); firm/team-scoped plans come later. + +-- ============================================================================= +-- 1. Trigger: trial grant on new company creation +-- ============================================================================= +-- SECURITY DEFINER so it can write capability_grants regardless of the caller's +-- RLS (the table has no INSERT policy for authenticated — writes are +-- service-role/definer only, so a user can never self-grant). +CREATE OR REPLACE FUNCTION public.seed_trial_capability_grants() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at) + SELECT NEW.id, k.key, 'trial', NEW.created_at + interval '30 days' + FROM (VALUES ('ai'), ('bank_sync'), ('skatteverket'), ('email_send')) AS k(key) + ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_seed_trial_capability_grants + AFTER INSERT ON public.companies + FOR EACH ROW EXECUTE FUNCTION public.seed_trial_capability_grants(); + +-- ============================================================================= +-- 2. Backfill: existing companies get a trial until the cutover +-- ============================================================================= +-- created on/before 2026-06-07 (i.e. before 2026-06-08) -> ends 2026-07-07; +-- created after -> created_at + 30 days. Skips already-granted rows. +INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at) +SELECT + c.id, + k.key, + 'trial', + CASE + WHEN c.created_at < timestamptz '2026-06-08 00:00:00+00' + THEN timestamptz '2026-07-07 00:00:00+00' + ELSE c.created_at + interval '30 days' + END +FROM public.companies c +CROSS JOIN (VALUES ('ai'), ('bank_sync'), ('skatteverket'), ('email_send')) AS k(key) +WHERE c.archived_at IS NULL +ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING; + +-- ============================================================================= +-- 3. Comp companies: permanent grants (never expire) +-- ============================================================================= +-- Verified against prod (project pwxtzglxptnnvjrpixpg) on 2026-06-29: +-- Arcim Technology AB — org 5595386219 (active), plus a no-org +-- "Arcim technology AB" variant (active) and 3 archived dupes +-- Mattsson Systems AB — org 5595719864 (active) +-- Match by org_number OR case-insensitive name, ACTIVE companies only. This is +-- robust to the case variant, the missing-org variant, and future renames; it +-- excludes the archived dupes and the unrelated "Amnäs Mattsson, Emil" enskild +-- firma. No hardcoded UUIDs; idempotent. +INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at) +SELECT c.id, k.key, 'comp', NULL +FROM public.companies c +CROSS JOIN (VALUES ('ai'), ('bank_sync'), ('skatteverket'), ('email_send')) AS k(key) +WHERE c.archived_at IS NULL + AND ( + c.org_number IN ('5595386219', '5595719864') + OR LOWER(c.name) IN ('arcim technology ab', 'mattsson systems ab') + ) +ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260629140000_stripe_subscriptions.sql b/supabase/migrations/20260629140000_stripe_subscriptions.sql new file mode 100644 index 00000000..0a27796e --- /dev/null +++ b/supabase/migrations/20260629140000_stripe_subscriptions.sql @@ -0,0 +1,44 @@ +-- Stripe subscription state + webhook idempotency for the SaaS paywall. +-- Payment provisioning writes capability_grants(source='stripe') (see +-- 20260628140000); this table links a company to its Stripe customer/ +-- subscription and tracks status. stripe_webhook_events dedupes retried +-- webhook deliveries. + +CREATE TABLE public.company_subscriptions ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + company_id uuid NOT NULL UNIQUE REFERENCES public.companies(id) ON DELETE CASCADE, + stripe_customer_id text, + stripe_subscription_id text, + status text, -- Stripe subscription status: active|trialing|past_due|canceled|... + plan text, -- 'monthly' | 'yearly' + current_period_end timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_company_subscriptions_customer ON public.company_subscriptions (stripe_customer_id); +CREATE INDEX idx_company_subscriptions_subscription ON public.company_subscriptions (stripe_subscription_id); + +ALTER TABLE public.company_subscriptions ENABLE ROW LEVEL SECURITY; + +-- Members may read their company's subscription status (billing page). Writes +-- are service-role only (checkout route + Stripe webhook) — a user can never +-- fabricate a subscription. +CREATE POLICY "members read company_subscriptions" + ON public.company_subscriptions FOR SELECT + USING (company_id IN (SELECT public.user_company_ids())); + +CREATE TRIGGER set_updated_at_company_subscriptions + BEFORE UPDATE ON public.company_subscriptions + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- Webhook idempotency: each processed Stripe event id is logged after handling; +-- duplicate deliveries are skipped. Service-role only (RLS on, no policies). +CREATE TABLE public.stripe_webhook_events ( + event_id text PRIMARY KEY, + type text NOT NULL, + processed_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE public.stripe_webhook_events ENABLE ROW LEVEL SECURITY; + +NOTIFY pgrst, 'reload schema';