diff --git a/.env.example b/.env.example index 765ae5df9..14c23b4d9 100644 --- a/.env.example +++ b/.env.example @@ -76,6 +76,19 @@ LIGHTHOUSE_MEMORY_SHED_THRESHOLD_MB=600 # LIGHTHOUSE_BIN=/usr/local/bin/lighthouse # CHROMIUM_BIN=/usr/bin/chromium +# ── Stripe Billing ───────────────────────────────────────────────── +# Production: live keys via 1Password → hover-runtime item +# Review apps: test keys via 1Password → hover-stripe-test item +# Local dev: use Stripe test keys and the Stripe CLI listener +# stripe listen --forward-to localhost:8080/v1/webhooks/stripe +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx +STRIPE_PUBLISHABLE_KEY=pk_test_xxx +# Optional: Customer Portal Configuration ID. When unset, Stripe uses the +# account's default portal configuration. Override per-environment to control +# which products appear in the portal. +STRIPE_PORTAL_CONFIG_ID=bpc_xxx + # ── Development Overrides ────────────────────────────────────────── # These are typically set in .env.local (auto-generated by dev.sh) # DEBUG=true diff --git a/.env.op b/.env.op new file mode 100644 index 000000000..dc9e72367 --- /dev/null +++ b/.env.op @@ -0,0 +1,17 @@ +# Dev secrets pulled from 1Password via op inject. +# Injected automatically by dev.sh when the op CLI is available and signed in. +# No secrets here — only op:// references. Safe to commit. + +# ── OAuth client secrets ────────────────────────────────────────────────────── +SLACK_CLIENT_SECRET=op://Good Native/hover-runtime/SLACK_CLIENT_SECRET +WEBFLOW_CLIENT_SECRET=op://Good Native/hover-runtime/WEBFLOW_CLIENT_SECRET +GOOGLE_CLIENT_SECRET=op://Good Native/hover-runtime/GOOGLE_CLIENT_SECRET + +# ── Email ───────────────────────────────────────────────────────────────────── +LOOPS_API_KEY=op://Good Native/hover-runtime/LOOPS_API_KEY + +# ── Stripe (test keys) ──────────────────────────────────────────────────────── +# STRIPE_WEBHOOK_SECRET is intentionally absent — it is session-specific and +# comes from the Stripe CLI: stripe listen --forward-to localhost:8080/v1/webhooks/stripe +STRIPE_SECRET_KEY=op://Good Native/hover-stripe/_TEST_STRIPE_SECRET_KEY +STRIPE_PUBLISHABLE_KEY=op://Good Native/hover-stripe/_TEST_STRIPE_PUBLISHABLE_KEY diff --git a/.fly/review_apps.toml b/.fly/review_apps.toml index b90746304..533856eb6 100644 --- a/.fly/review_apps.toml +++ b/.fly/review_apps.toml @@ -36,6 +36,9 @@ SUPABASE_PUBLISHABLE_KEY = "sb_publishable_xCn-z8wEo4MPJnbPq6cvXg_09fINjIp" SLACK_CLIENT_ID = "475806237719.8953791603746" WEBFLOW_CLIENT_ID = "b0a05758d95823fcef73f57e836e55232bf7c5730794222786040800230ea329" GOOGLE_CLIENT_ID = "721107686014-okh41udv0lkb40ijnsoeh53unj9kn0en.apps.googleusercontent.com" +# Stripe Customer Portal Configuration (sandbox) — controls which products appear, +# return URL pattern, etc. The bpc_ ID is non-sensitive so lives in env, not 1Password. +STRIPE_PORTAL_CONFIG_ID = "bpc_1TRIpQS2RiCh0hZBmnJjlnDH" # Cold-storage archival (R2) — credentials via flyctl secrets ARCHIVE_PROVIDER = "r2" diff --git a/.github/actions/fly-setup/action.yml b/.github/actions/fly-setup/action.yml index ecb84042a..312a9ebcf 100644 --- a/.github/actions/fly-setup/action.yml +++ b/.github/actions/fly-setup/action.yml @@ -77,6 +77,13 @@ runs: ARCHIVE_SECRET_ACCESS_KEY: op://Good Native/hover-archive/ARCHIVE_SECRET_ACCESS_KEY ARCHIVE_ENDPOINT: op://Good Native/hover-archive/ARCHIVE_ENDPOINT + # Stripe billing — live keys; review apps override these with test + # keys before staging. + STRIPE_SECRET_KEY: op://Good Native/hover-stripe/STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET: + op://Good Native/hover-stripe/STRIPE_WEBHOOK_SECRET + STRIPE_PUBLISHABLE_KEY: + op://Good Native/hover-stripe/STRIPE_PUBLISHABLE_KEY - uses: superfly/flyctl-actions/setup-flyctl@63da3ecc5e2793b98a3f2519b3d75d4f4c11cec2 # pinned @@ -88,3 +95,12 @@ runs: echo "❌ REDIS_URL is required. Check 1Password: op://Good Native/hover-runtime/REDIS_URL" >&2 exit 1 fi + # Stripe live keys are loaded above; review apps override these with + # test keys before staging, so validation only runs in the prod path + # (gated by validate-redis-url == 'true'). + for key in STRIPE_SECRET_KEY STRIPE_WEBHOOK_SECRET STRIPE_PUBLISHABLE_KEY; do + if [ -z "${!key}" ]; then + echo "❌ ${key} is required for Stripe billing. Check 1Password: op://Good Native/hover-stripe/${key}" >&2 + exit 1 + fi + done diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml index 6ae33bdb3..21e2830cf 100644 --- a/.github/workflows/fly-deploy.yml +++ b/.github/workflows/fly-deploy.yml @@ -119,6 +119,9 @@ jobs: GRAFANA_CLOUD_USER="$GRAFANA_CLOUD_USER" \ GRAFANA_CLOUD_API_KEY="$GRAFANA_CLOUD_API_KEY" \ REDIS_URL="$REDIS_URL" \ + STRIPE_SECRET_KEY="$STRIPE_SECRET_KEY" \ + STRIPE_WEBHOOK_SECRET="$STRIPE_WEBHOOK_SECRET" \ + STRIPE_PUBLISHABLE_KEY="$STRIPE_PUBLISHABLE_KEY" \ --stage - name: Release API app diff --git a/.github/workflows/review-apps.yml b/.github/workflows/review-apps.yml index 4f77eac5b..db6d99d08 100644 --- a/.github/workflows/review-apps.yml +++ b/.github/workflows/review-apps.yml @@ -66,6 +66,21 @@ jobs: # the composite is irrelevant here and must not be validated. validate-redis-url: "false" + - name: Load Stripe test keys + # Review apps deliberately use Stripe TEST keys so PR work can't move + # real money. These overlay any prod values that might leak in. + uses: 1password/load-secrets-action@v2 + with: + export-env: true + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + STRIPE_SECRET_KEY: + op://Good Native/hover-stripe/_TEST_STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET: + op://Good Native/hover-stripe/_TEST_STRIPE_WEBHOOK_SECRET + STRIPE_PUBLISHABLE_KEY: + op://Good Native/hover-stripe/_TEST_STRIPE_PUBLISHABLE_KEY + - name: Setup Supabase CLI uses: supabase/setup-cli@v1 with: @@ -317,6 +332,9 @@ jobs: ARCHIVE_ENDPOINT="$ARCHIVE_ENDPOINT" \ GRAFANA_CLOUD_USER="$GRAFANA_CLOUD_USER" \ GRAFANA_CLOUD_API_KEY="$GRAFANA_CLOUD_API_KEY" \ + STRIPE_SECRET_KEY="$STRIPE_SECRET_KEY" \ + STRIPE_WEBHOOK_SECRET="$STRIPE_WEBHOOK_SECRET" \ + STRIPE_PUBLISHABLE_KEY="$STRIPE_PUBLISHABLE_KEY" \ WEBFLOW_REDIRECT_URI="${API_URL}/v1/integrations/webflow/callback" \ APP_URL="$API_URL" \ SETTINGS_URL="${API_URL}/settings" \ diff --git a/.gitignore b/.gitignore index 298fa3f41..94540a5a3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .env .env.* !.env.example +!.env.op # Dependencies /vendor/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0755be6..51cb2d8ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,63 @@ On merge, CI will: 4. Create a git tag and GitHub release 5. Commit the updated changelog -## [Unreleased] +## [Unreleased:minor] + +### Added + +- Stripe billing integration — Checkout Sessions, Billing Portal, and webhook + event handling at `POST /v1/webhooks/stripe` (`checkout.session.completed`, + `customer.subscription.updated`, `customer.subscription.deleted`, + `invoice.payment_failed`). +- New plan tiers: Starter ($19/200 pages), Plus ($49/1000), Pro ($149/10000), + Ultra ($399/100000), Max ($849/500000); deactivated old business/enterprise + tiers. +- `POST /v1/billing/checkout` — admin-only. Free → paid creates a Stripe + Checkout Session; paid → different paid updates the existing subscription in + place via Stripe's API (Stripe-managed proration, no duplicate subscriptions). +- `POST /v1/billing/portal` — admin-only. Opens a Stripe Customer Portal session + for self-service subscription management. Honours an optional + `STRIPE_PORTAL_CONFIG_ID` env var so the portal config is environment-aware + (live vs sandbox `bpc_…`). +- `POST /v1/billing/cancel` — admin-only. Schedules cancellation at the end of + the current billing period via Stripe's `cancel_at_period_end` flag; the + customer keeps paid features through what they've already paid for, then + auto-downgrades to free when the period ends. +- Settings → Plans: Upgrade / Switch / Manage Billing buttons; success toast + with period-end date on cancellation; usage-cache invalidation on plan change + and org switch. +- Stripe secrets managed via 1Password for both review apps (test keys) and + production (live keys); `fly-setup` action validates the live keys are present + before deploy. +- `dev.sh` now auto-injects external secrets (Stripe, Slack, Webflow, Google, + Loops) from 1Password via `op inject` when `op` CLI is available. +- `.env.op` — committed `op://` template for local dev secrets. + +### Changed + +- Webhook handler tolerates Stripe API-version drift between the destination and + the SDK (`webhook.ConstructEventWithOptions` with + `IgnoreAPIVersionMismatch: true`), so a Stripe SDK upgrade doesn't break + signature verification for events from older webhook destinations. +- Webhook handlers ACK (return 200) for events about unknown customers or + unmapped Stripe price IDs rather than 5xx — Stripe stops retrying for + permanent misconfigurations rather than spinning forever. +- Webhook handlers ignore events for subscriptions that don't match the + organisation's stored `stripe_subscription_id` — protects against zombie + subscriptions on the same Stripe customer flipping the wrong org's plan. + +### Fixed + +- Checkout Session creation uses an idempotency key (`checkout::`), + so a double-clicked Upgrade button or proxy retry can't create duplicate + Stripe subscriptions. +- "Switch to Free" now actually cancels the Stripe subscription server-side + (previously it only updated the local plan column, leaving the customer billed + indefinitely). +- `BillingCheckout` defensively reconciles with Stripe before creating a new + Checkout Session: if Stripe has an active sub for the customer that the local + DB doesn't know about (e.g. webhook outage), it adopts the existing sub and + takes the in-place update path. _Add unreleased changes here._ diff --git a/cmd/app/main.go b/cmd/app/main.go index 4cabc76d2..041fe971b 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -346,6 +346,10 @@ type Config struct { OTLPEndpoint string OTLPHeaders string OTLPInsecure bool + StripeSecretKey string + StripeWebhookSecret string + StripePublishableKey string + StripePortalConfigID string } //nolint:gocyclo // main function setup is naturally complex but straightforward setup logic @@ -373,6 +377,10 @@ func main() { OTLPEndpoint: os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), OTLPHeaders: os.Getenv("OTEL_EXPORTER_OTLP_HEADERS"), OTLPInsecure: getEnvWithDefault("OTEL_EXPORTER_OTLP_INSECURE", "false") == "true", + StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), + StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), + StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"), + StripePortalConfigID: os.Getenv("STRIPE_PORTAL_CONFIG_ID"), } if config.FlightRecorderEnabled { @@ -648,6 +656,11 @@ func main() { brokerCleaner, googleClientID, googleClientSecret, + config.StripeSecretKey, + config.StripeWebhookSecret, + config.StripePublishableKey, + config.StripePortalConfigID, + getEnvWithDefault("SETTINGS_URL", ""), ) mux := http.NewServeMux() diff --git a/dev.sh b/dev.sh index 9c3dc3c83..ae4f84d90 100755 --- a/dev.sh +++ b/dev.sh @@ -132,6 +132,44 @@ else done fi +inject_op_secrets() { + if ! command -v op >/dev/null 2>&1; then + echo "⚠️ 1Password CLI (op) not found — external secrets not loaded." + echo " Install: brew install 1password-cli" + echo " Missing: SLACK_CLIENT_SECRET, WEBFLOW_CLIENT_SECRET," + echo " GOOGLE_CLIENT_SECRET, LOOPS_API_KEY," + echo " STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY" + echo " For Stripe webhooks locally, also run:" + echo " stripe listen --forward-to localhost:8080/v1/webhooks/stripe" + return + fi + + if ! op whoami >/dev/null 2>&1; then + echo "⚠️ Not signed in to 1Password CLI — external secrets not loaded." + echo " Sign in with: op signin" + return + fi + + # Strip previously injected keys before re-injecting (avoids duplicates on re-run). + for key in SLACK_CLIENT_SECRET WEBFLOW_CLIENT_SECRET GOOGLE_CLIENT_SECRET \ + LOOPS_API_KEY STRIPE_SECRET_KEY STRIPE_PUBLISHABLE_KEY; do + sed -i.bak "/^${key}=/d" .env.local + done + rm -f .env.local.bak + + echo "Loading external secrets from 1Password..." + if op inject -i .env.op >> .env.local 2>/dev/null; then + echo "✅ External secrets loaded from 1Password" + echo " For Stripe webhooks locally, also run:" + echo " stripe listen --forward-to localhost:8080/v1/webhooks/stripe" + echo " Then add STRIPE_WEBHOOK_SECRET= to .env.local" + else + echo "⚠️ Failed to load secrets from 1Password — check op vault access" + fi +} + +inject_op_secrets + # Start Air with hot reloading and migration watching echo "Starting development server with hot reloading..." echo "Watching for migration changes - will auto-reset database when needed..." diff --git a/docs/development/DEVELOPMENT.md b/docs/development/DEVELOPMENT.md index b31ee762f..7623cebd4 100644 --- a/docs/development/DEVELOPMENT.md +++ b/docs/development/DEVELOPMENT.md @@ -12,6 +12,9 @@ - **Git** - Version control - **golangci-lint** (optional) - Code quality checks (`brew install golangci-lint`) +- **1Password CLI** (optional but recommended) — loads external API secrets + automatically (`brew install 1password-cli`). Without it, you'll need to add + Stripe/Slack/Webflow/Google/Loops keys to `.env.local` manually. ## Quick Setup diff --git a/fly.toml b/fly.toml index 6d93e3fe9..9b77e3102 100644 --- a/fly.toml +++ b/fly.toml @@ -48,6 +48,10 @@ primary_region = 'syd' SLACK_CLIENT_ID = "475806237719.8953791603746" WEBFLOW_CLIENT_ID = "b0a05758d95823fcef73f57e836e55232bf7c5730794222786040800230ea329" GOOGLE_CLIENT_ID = "721107686014-okh41udv0lkb40ijnsoeh53unj9kn0en.apps.googleusercontent.com" + # Stripe Customer Portal Configuration (live). Leave empty until a live-mode + # bpc_ ID is created in the Stripe dashboard; the code falls back to the + # account's default portal config when this is unset. + STRIPE_PORTAL_CONFIG_ID = "bpc_1TRfqpS2RiCh0hZB6SklbEdN" OTEL_EXPORTER_OTLP_ENDPOINT = "https://otlp-gateway-prod-au-southeast-1.grafana.net/otlp/v1/traces" # Cold-storage archival (R2) — defaults in internal/archive/archive.go ARCHIVE_PROVIDER = "r2" diff --git a/go.mod b/go.mod index d5aee06d7..39b29a57f 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/redis/go-redis/v9 v9.18.0 github.com/slack-go/slack v0.17.3 github.com/stretchr/testify v1.11.1 + github.com/stripe/stripe-go/v82 v82.5.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 diff --git a/go.sum b/go.sum index c10b16fae..60314dcc7 100644 --- a/go.sum +++ b/go.sum @@ -181,6 +181,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8= +github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8= github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/internal/api/billing.go b/internal/api/billing.go new file mode 100644 index 000000000..019582c35 --- /dev/null +++ b/internal/api/billing.go @@ -0,0 +1,417 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/Harvey-AU/hover/internal/logging" + stripe "github.com/stripe/stripe-go/v82" + portalsession "github.com/stripe/stripe-go/v82/billingportal/session" + checkoutsession "github.com/stripe/stripe-go/v82/checkout/session" + "github.com/stripe/stripe-go/v82/customer" + stripesubscription "github.com/stripe/stripe-go/v82/subscription" +) + +var billingLog = logging.Component("billing") + +// BillingCheckout starts a paid-plan transition for the active organisation. +// POST /v1/billing/checkout +// Body: {"plan_id": ""} +// +// Behaviour depends on whether the org already has an active Stripe +// subscription: +// - No subscription → creates a Stripe Checkout Session and returns its URL. +// The caller redirects the browser to checkout.stripe.com to collect +// payment; on completion Stripe fires checkout.session.completed which +// activates the plan via stripe_webhook.go. +// - Existing subscription → updates the subscription's line item in place +// via the Stripe Subscriptions API (Stripe handles proration). No browser +// redirect to Stripe is needed; the customer.subscription.updated webhook +// reconciles plan_id in the DB. Returns a same-origin success URL so the +// frontend can render its standard ?billing=success toast. +func (h *Handler) BillingCheckout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + MethodNotAllowed(w, r) + return + } + + if h.StripeSecretKey == "" { + billingLog.ErrorContext(r.Context(), "Stripe secret key not configured") + InternalError(w, r, fmt.Errorf("billing not configured")) + return + } + + user, orgID, ok := h.GetActiveOrganisationWithUser(w, r) + if !ok { + return + } + + role, err := h.DB.GetOrganisationMemberRole(r.Context(), user.ID, orgID) + if err != nil { + billingLog.ErrorContext(r.Context(), "Failed to look up organisation member role", "error", err, "org_id", orgID, "user_id", user.ID) + InternalError(w, r, fmt.Errorf("failed to verify membership")) + return + } + if role != "admin" { + Forbidden(w, r, "Only organisation admins can manage billing") + return + } + + var body struct { + PlanID string `json:"plan_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.PlanID == "" { + BadRequest(w, r, "plan_id is required") + return + } + + // Fetch all plans to find the requested one with its Stripe Price ID. + plans, err := h.DB.GetActivePlans(r.Context()) + if err != nil { + InternalError(w, r, fmt.Errorf("failed to fetch plans: %w", err)) + return + } + var stripePriceID string + for _, p := range plans { + if p.ID == body.PlanID { + stripePriceID = p.StripePriceID + break + } + } + if stripePriceID == "" { + BadRequest(w, r, "Plan not found or not available for purchase") + return + } + + // Branch on existing subscription state. + existingSubID, err := h.DB.GetStripeSubscriptionID(r.Context(), orgID) + if err != nil { + InternalError(w, r, fmt.Errorf("failed to check existing subscription: %w", err)) + return + } + + // Get customer ID up-front. Used both for the defensive Stripe-side check + // below and for the new-Checkout flow further down. + customerID, err := h.DB.GetStripeCustomerID(r.Context(), orgID) + if err != nil { + InternalError(w, r, fmt.Errorf("failed to fetch stripe customer: %w", err)) + return + } + + // Defensive: if our DB has no subscription but Stripe has an active one for + // this customer, adopt it. Catches drift caused by missed webhooks (the + // Apr 28 zombie scenario where signature verification failed and DB never + // learned about the new sub) — without this, a second checkout click would + // create a duplicate live subscription. + if existingSubID == "" && customerID != "" { + listParams := &stripe.SubscriptionListParams{ + Customer: stripe.String(customerID), + Status: stripe.String("active"), + } + listParams.Context = r.Context() + iter := stripesubscription.List(listParams) + // Adopt the first active subscription we find. If there are multiple, + // the others are leftover zombies — Stripe Customer Portal can clean + // those up. We only need one to take the in-place update path. + if iter.Next() { + adopted := iter.Subscription() + // Recovery path — expected when our DB has fallen behind Stripe. + // NoCapture so we don't page Sentry every time a webhook outage + // is being healed. + billingLog.WarnContext(logging.NoCapture(r.Context()), "Adopting orphan Stripe subscription not tracked in DB", "org_id", orgID, "subscription_id", adopted.ID) + if err := h.DB.SetStripeSubscriptionID(r.Context(), orgID, adopted.ID); err != nil { + billingLog.ErrorContext(r.Context(), "Failed to adopt orphan Stripe subscription", "error", err, "org_id", orgID) + } else { + existingSubID = adopted.ID + } + } + if err := iter.Err(); err != nil { + billingLog.WarnContext(r.Context(), "Failed to list Stripe subscriptions for orphan check", "error", err, "customer_id", customerID) + } + } + + baseURL := h.absoluteBaseURL(r) + settingsURL := baseURL + "/settings/plans" + + if existingSubID != "" { + // Paid → different paid: update the existing subscription in place. + // Avoids duplicate live subscriptions and gives Stripe-managed proration. + fetchParams := &stripe.SubscriptionParams{} + fetchParams.Context = r.Context() + sub, err := stripesubscription.Get(existingSubID, fetchParams) + if err != nil { + billingLog.ErrorContext(r.Context(), "Failed to fetch existing Stripe subscription", "error", err, "subscription_id", existingSubID) + InternalError(w, r, fmt.Errorf("failed to fetch subscription")) + return + } + if sub.Items == nil || len(sub.Items.Data) == 0 { + billingLog.ErrorContext(r.Context(), "Existing subscription has no line items", "subscription_id", existingSubID) + InternalError(w, r, fmt.Errorf("subscription has no items")) + return + } + // If they're already on this price, no-op back to the success page. + if sub.Items.Data[0].Price != nil && sub.Items.Data[0].Price.ID == stripePriceID { + WriteSuccess(w, r, map[string]string{"url": settingsURL + "?billing=success"}, "Already on this plan") + return + } + // Stripe defaults proration_behavior to "create_prorations" — credits + // for unused time on the old plan offset the new plan, applied to the + // next invoice. Switch to "always_invoice" if we ever need immediate + // billing on upgrade. + // + // Also clear cancel_at_period_end: an admin who scheduled cancellation + // and then picks a different tier is implicitly re-affirming the + // subscription, so we shouldn't keep them queued for cancellation. + updateParams := &stripe.SubscriptionParams{ + Items: []*stripe.SubscriptionItemsParams{ + { + ID: stripe.String(sub.Items.Data[0].ID), + Price: stripe.String(stripePriceID), + }, + }, + CancelAtPeriodEnd: stripe.Bool(false), + } + updateParams.Context = r.Context() + _, err = stripesubscription.Update(existingSubID, updateParams) + if err != nil { + billingLog.ErrorContext(r.Context(), "Failed to update Stripe subscription", "error", err, "org_id", orgID, "subscription_id", existingSubID) + InternalError(w, r, fmt.Errorf("failed to update subscription")) + return + } + // Synchronously update plan_id so the immediate redirect-back to + // /settings/plans renders the new plan without waiting for the + // customer.subscription.updated webhook to land. The webhook handler + // runs idempotently when it arrives. + if err := h.DB.SetOrganisationPlan(r.Context(), orgID, body.PlanID); err != nil { + // Don't fail the request — Stripe is already updated, the webhook + // will correct the DB shortly. Just log so the lag is visible. + billingLog.ErrorContext(r.Context(), "Failed to sync plan_id locally after subscription update — relying on webhook reconciliation", "error", err, "org_id", orgID, "plan_id", body.PlanID) + } + billingLog.InfoContext(r.Context(), "Updated Stripe subscription to new plan", "org_id", orgID, "subscription_id", existingSubID, "price_id", stripePriceID) + WriteSuccess(w, r, map[string]string{"url": settingsURL + "?billing=success"}, "Plan updated") + return + } + + // No existing subscription — first-time Checkout flow. + if customerID == "" { + org, err := h.DB.GetOrganisation(orgID) + if err != nil { + InternalError(w, r, fmt.Errorf("failed to fetch organisation: %w", err)) + return + } + custParams := &stripe.CustomerParams{ + Email: stripe.String(user.Email), + Name: stripe.String(org.Name), + Metadata: map[string]string{ + "organisation_id": orgID, + }, + } + custParams.Context = r.Context() + cust, err := customer.New(custParams) + if err != nil { + billingLog.ErrorContext(r.Context(), "Failed to create Stripe customer", "error", err, "org_id", orgID) + InternalError(w, r, fmt.Errorf("failed to create billing customer")) + return + } + customerID = cust.ID + billingLog.InfoContext(r.Context(), "Created Stripe customer", "customer_id", customerID, "org_id", orgID) + if err := h.DB.SetStripeCustomerID(r.Context(), orgID, customerID); err != nil { + billingLog.ErrorContext(r.Context(), "Failed to store Stripe customer ID", "error", err, "org_id", orgID) + InternalError(w, r, fmt.Errorf("failed to store billing customer")) + return + } + } + + sessParams := &stripe.CheckoutSessionParams{ + Customer: stripe.String(customerID), + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(stripePriceID), + Quantity: stripe.Int64(1), + }, + }, + SuccessURL: stripe.String(settingsURL + "?billing=success"), + CancelURL: stripe.String(settingsURL + "?billing=cancelled"), + ClientReferenceID: stripe.String(orgID), + } + // Idempotency key — Stripe returns the original session for repeated + // requests with the same key (24h window). Protects against double-clicks, + // network retries, and proxy retries handing the same org multiple + // checkout URLs that could complete to duplicate subscriptions. The key + // is org+price-scoped so legitimate switches to different prices still + // create fresh sessions. + sessParams.SetIdempotencyKey(fmt.Sprintf("checkout:%s:%s", orgID, stripePriceID)) + sessParams.Context = r.Context() + sess, err := checkoutsession.New(sessParams) + if err != nil { + billingLog.ErrorContext(r.Context(), "Failed to create Stripe Checkout Session", "error", err, "org_id", orgID) + InternalError(w, r, fmt.Errorf("failed to create checkout session")) + return + } + + WriteSuccess(w, r, map[string]string{"url": sess.URL}, "Checkout session created") +} + +// BillingPortal creates a Stripe Billing Portal session for managing subscriptions. +// POST /v1/billing/portal +// Returns: {"url": "https://billing.stripe.com/..."} +func (h *Handler) BillingPortal(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + MethodNotAllowed(w, r) + return + } + + if h.StripeSecretKey == "" { + billingLog.ErrorContext(r.Context(), "Stripe secret key not configured") + InternalError(w, r, fmt.Errorf("billing not configured")) + return + } + + user, orgID, ok := h.GetActiveOrganisationWithUser(w, r) + if !ok { + return + } + + role, err := h.DB.GetOrganisationMemberRole(r.Context(), user.ID, orgID) + if err != nil { + billingLog.ErrorContext(r.Context(), "Failed to look up organisation member role", "error", err, "org_id", orgID, "user_id", user.ID) + InternalError(w, r, fmt.Errorf("failed to verify membership")) + return + } + if role != "admin" { + Forbidden(w, r, "Only organisation admins can manage billing") + return + } + + customerID, err := h.DB.GetStripeCustomerID(r.Context(), orgID) + if err != nil { + InternalError(w, r, fmt.Errorf("failed to fetch billing info: %w", err)) + return + } + if customerID == "" { + BadRequest(w, r, "No billing account found — upgrade to a paid plan first") + return + } + + params := &stripe.BillingPortalSessionParams{ + Customer: stripe.String(customerID), + ReturnURL: stripe.String(h.absoluteBaseURL(r) + "/settings/plans"), + } + params.Context = r.Context() + // Optional override — when unset, Stripe falls back to the account's + // default Customer Portal configuration. + if h.StripePortalConfigID != "" { + params.Configuration = stripe.String(h.StripePortalConfigID) + } + sess, err := portalsession.New(params) + if err != nil { + billingLog.ErrorContext(r.Context(), "Failed to create Stripe Portal Session", "error", err, "org_id", orgID) + InternalError(w, r, fmt.Errorf("failed to create portal session")) + return + } + + WriteSuccess(w, r, map[string]string{"url": sess.URL}, "Portal session created") +} + +// BillingCancel schedules cancellation of the org's Stripe subscription at the +// end of the current billing period. POST /v1/billing/cancel. Admin-only. +// +// We use cancel_at_period_end semantics rather than immediate cancellation: +// the customer keeps paid features through the period they have already paid +// for, and Stripe transitions the subscription to "canceled" at the period +// end — at which point the customer.subscription.deleted webhook fires and +// our handler downgrades the org to free. +// +// The DB is intentionally NOT updated synchronously here — the org should +// remain on its current paid plan until the period actually ends. If the +// admin changes their mind during this window, BillingCheckout's update path +// resets cancel_at_period_end back to false. +// +// Returns the period_end unix timestamp so the frontend can render +// "Your plan stays active until ". +func (h *Handler) BillingCancel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + MethodNotAllowed(w, r) + return + } + + if h.StripeSecretKey == "" { + billingLog.ErrorContext(r.Context(), "Stripe secret key not configured") + InternalError(w, r, fmt.Errorf("billing not configured")) + return + } + + user, orgID, ok := h.GetActiveOrganisationWithUser(w, r) + if !ok { + return + } + + role, err := h.DB.GetOrganisationMemberRole(r.Context(), user.ID, orgID) + if err != nil { + billingLog.ErrorContext(r.Context(), "Failed to look up organisation member role", "error", err, "org_id", orgID, "user_id", user.ID) + InternalError(w, r, fmt.Errorf("failed to verify membership")) + return + } + if role != "admin" { + Forbidden(w, r, "Only organisation admins can manage billing") + return + } + + subID, err := h.DB.GetStripeSubscriptionID(r.Context(), orgID) + if err != nil { + InternalError(w, r, fmt.Errorf("failed to fetch subscription: %w", err)) + return + } + if subID == "" { + // No active subscription to cancel — nothing to do. Treat as success + // so the caller's flow is idempotent. + WriteSuccess(w, r, map[string]any{"success": true}, "No active subscription to cancel") + return + } + + cancelParams := &stripe.SubscriptionParams{ + CancelAtPeriodEnd: stripe.Bool(true), + } + cancelParams.Context = r.Context() + sub, err := stripesubscription.Update(subID, cancelParams) + if err != nil { + billingLog.ErrorContext(r.Context(), "Failed to schedule subscription cancellation", "error", err, "org_id", orgID, "subscription_id", subID) + InternalError(w, r, fmt.Errorf("failed to cancel subscription")) + return + } + billingLog.InfoContext(r.Context(), "Scheduled Stripe subscription cancellation at period end", "org_id", orgID, "subscription_id", subID) + + // Pull period_end from the first line item — Stripe v82 moved it from the + // subscription itself onto each item. + var periodEnd int64 + if sub.Items != nil && len(sub.Items.Data) > 0 && sub.Items.Data[0] != nil { + periodEnd = sub.Items.Data[0].CurrentPeriodEnd + } + + WriteSuccess(w, r, map[string]any{ + "success": true, + "period_end": periodEnd, + }, "Subscription cancellation scheduled") +} + +// absoluteBaseURL returns the scheme+host base URL for this request. +// Uses X-Forwarded-Proto when behind a proxy, falls back to https. +func (h *Handler) absoluteBaseURL(r *http.Request) string { + if h.SettingsURL != "" { + // Strip trailing /settings if the caller stored the full page URL. + base := h.SettingsURL + if len(base) > 9 && base[len(base)-9:] == "/settings" { + base = base[:len(base)-9] + } + return base + } + scheme := "https" + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + scheme = proto + } else if r.TLS == nil { + scheme = "http" + } + return scheme + "://" + r.Host +} diff --git a/internal/api/errors.go b/internal/api/errors.go index eab87a627..9f3f32dd9 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -100,6 +100,10 @@ func NotFound(w http.ResponseWriter, r *http.Request, message string) { WriteErrorMessage(w, r, message, http.StatusNotFound, ErrCodeNotFound) } +func Conflict(w http.ResponseWriter, r *http.Request, message string) { + WriteErrorMessage(w, r, message, http.StatusConflict, ErrCodeConflict) +} + func MethodNotAllowed(w http.ResponseWriter, r *http.Request) { WriteErrorMessage(w, r, "Method not allowed", http.StatusMethodNotAllowed, ErrCodeMethodNotAllowed) } diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 9b65fc0a7..7bbdaecf6 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -21,6 +21,7 @@ import ( "github.com/Harvey-AU/hover/internal/jobs" "github.com/Harvey-AU/hover/internal/logging" "github.com/Harvey-AU/hover/internal/loops" + stripe "github.com/stripe/stripe-go/v82" ) // Set via ldflags at build time. @@ -187,6 +188,15 @@ type DBClient interface { GetPlatformOrgMapping(ctx context.Context, platform, platformID string) (*db.PlatformOrgMapping, error) GetOrganisationUsageStats(ctx context.Context, orgID string) (*db.UsageStats, error) GetActivePlans(ctx context.Context) ([]db.Plan, error) + // Stripe billing methods + SetStripeCustomerID(ctx context.Context, organisationID, customerID string) error + GetStripeCustomerID(ctx context.Context, organisationID string) (string, error) + GetOrganisationIDByStripeCustomerID(ctx context.Context, customerID string) (string, error) + SetStripeSubscriptionID(ctx context.Context, organisationID, subscriptionID string) error + GetStripeSubscriptionID(ctx context.Context, organisationID string) (string, error) + GetPlanByStripePriceID(ctx context.Context, priceID string) (*db.Plan, error) + GetFreePlanID(ctx context.Context) (string, error) + // Webflow site settings methods CreateOrUpdateSiteSetting(ctx context.Context, setting *db.WebflowSiteSetting) error GetSiteSetting(ctx context.Context, organisationID, webflowSiteID string) (*db.WebflowSiteSetting, error) GetSiteSettingByID(ctx context.Context, id string) (*db.WebflowSiteSetting, error) @@ -205,23 +215,35 @@ type BrokerCleaner interface { } type Handler struct { - DB DBClient - JobsManager jobs.JobManagerInterface - Loops *loops.Client - Broker BrokerCleaner - GoogleClientID string - GoogleClientSecret string + DB DBClient + JobsManager jobs.JobManagerInterface + Loops *loops.Client + Broker BrokerCleaner + GoogleClientID string + GoogleClientSecret string + StripeSecretKey string + StripeWebhookSecret string + StripePublishableKey string + StripePortalConfigID string + SettingsURL string } // broker may be nil when Redis is not configured — admin reset endpoints skip the Redis clear in that case. -func NewHandler(pgDB DBClient, jobsManager jobs.JobManagerInterface, loopsClient *loops.Client, broker BrokerCleaner, googleClientID, googleClientSecret string) *Handler { +func NewHandler(pgDB DBClient, jobsManager jobs.JobManagerInterface, loopsClient *loops.Client, broker BrokerCleaner, googleClientID, googleClientSecret, stripeSecretKey, stripeWebhookSecret, stripePublishableKey, stripePortalConfigID, settingsURL string) *Handler { + // Set the Stripe API key once at startup to avoid concurrent global writes. + stripe.Key = stripeSecretKey return &Handler{ - DB: pgDB, - JobsManager: jobsManager, - Loops: loopsClient, - Broker: broker, - GoogleClientID: googleClientID, - GoogleClientSecret: googleClientSecret, + DB: pgDB, + JobsManager: jobsManager, + Loops: loopsClient, + Broker: broker, + GoogleClientID: googleClientID, + GoogleClientSecret: googleClientSecret, + StripeSecretKey: stripeSecretKey, + StripeWebhookSecret: stripeWebhookSecret, + StripePublishableKey: stripePublishableKey, + StripePortalConfigID: stripePortalConfigID, + SettingsURL: settingsURL, } } @@ -311,6 +333,11 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux) { mux.Handle("/v1/plans", http.HandlerFunc(h.PlansHandler)) mux.HandleFunc("/v1/webhooks/webflow/", h.WebflowWebhook) + mux.HandleFunc("/v1/webhooks/stripe", h.StripeWebhook) + + mux.Handle("/v1/billing/checkout", auth.AuthMiddleware(http.HandlerFunc(h.BillingCheckout))) + mux.Handle("/v1/billing/portal", auth.AuthMiddleware(http.HandlerFunc(h.BillingPortal))) + mux.Handle("/v1/billing/cancel", auth.AuthMiddleware(http.HandlerFunc(h.BillingCancel))) mux.Handle("/v1/integrations/slack", auth.AuthMiddleware(http.HandlerFunc(h.SlackConnectionsHandler))) mux.Handle("/v1/integrations/slack/", auth.AuthMiddleware(http.HandlerFunc(h.SlackConnectionHandler))) diff --git a/internal/api/stripe_webhook.go b/internal/api/stripe_webhook.go new file mode 100644 index 000000000..0dc8b37d1 --- /dev/null +++ b/internal/api/stripe_webhook.go @@ -0,0 +1,337 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/Harvey-AU/hover/internal/db" + "github.com/Harvey-AU/hover/internal/logging" + stripe "github.com/stripe/stripe-go/v82" + stripesubscription "github.com/stripe/stripe-go/v82/subscription" + "github.com/stripe/stripe-go/v82/webhook" +) + +var webhookLog = logging.Component("stripe_webhook") + +// StripeWebhook handles incoming Stripe webhook events. +// POST /v1/webhooks/stripe — no auth, signature verified internally. +// +// Handlers return a non-nil error only for transient failures (DB or Stripe API +// errors). Permanent failures — malformed payloads, missing required fields, +// unknown plan IDs — are logged and swallowed so Stripe stops retrying. We +// reply 5xx on transient errors so Stripe re-queues the event per its dunning +// schedule. +func (h *Handler) StripeWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + MethodNotAllowed(w, r) + return + } + + if h.StripeWebhookSecret == "" { + webhookLog.ErrorContext(r.Context(), "Stripe webhook secret not configured — rejecting event") + http.Error(w, "webhook not configured", http.StatusServiceUnavailable) + return + } + + const maxBodyBytes = 65536 + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + // Expected for oversized payloads from a misbehaving sender — don't + // page Sentry; respond with a 400 so the sender can fix. + webhookLog.WarnContext(logging.NoCapture(r.Context()), "Failed to read Stripe webhook body", "error", err) + BadRequest(w, r, "Failed to read request body") + return + } + + // Tolerate API-version drift between the webhook destination's pinned + // version and the stripe-go SDK's expected version. The destination's + // version controls payload shape, not signing — and we deserialise + // fields conservatively. + event, err := webhook.ConstructEventWithOptions( + body, + r.Header.Get("Stripe-Signature"), + h.StripeWebhookSecret, + webhook.ConstructEventOptions{IgnoreAPIVersionMismatch: true}, + ) + if err != nil { + // Signature failure is more interesting — keep Sentry capture so we + // see if a misconfigured sender or a credential rotation breaks the + // pipeline. + webhookLog.WarnContext(r.Context(), "Stripe webhook signature verification failed", "error", err) + http.Error(w, "invalid signature", http.StatusBadRequest) + return + } + + logger := webhookLog.With( + "stripe_event_id", event.ID, + "stripe_event_type", string(event.Type), + ) + logger.InfoContext(r.Context(), "Received Stripe webhook event") + + var handlerErr error + switch event.Type { + case "checkout.session.completed": + handlerErr = h.handleCheckoutSessionCompleted(r, event, logger) + case "customer.subscription.updated": + handlerErr = h.handleSubscriptionUpdated(r, event, logger) + case "customer.subscription.deleted": + handlerErr = h.handleSubscriptionDeleted(r, event, logger) + case "invoice.payment_failed": + h.handleInvoicePaymentFailed(r, event, logger) + default: + logger.DebugContext(r.Context(), "Unhandled Stripe event type — ignoring") + } + + if handlerErr != nil { + // Already logged at the failure site (with appropriate Sentry capture); + // suppress capture here to avoid duplicating the issue. + logger.ErrorContext(logging.NoCapture(r.Context()), "Stripe webhook handler reported transient failure — returning 5xx so Stripe retries", "error", handlerErr) + http.Error(w, "transient processing failure", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (h *Handler) handleCheckoutSessionCompleted(r *http.Request, event stripe.Event, logger *logging.Logger) error { + var sess stripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { + logger.ErrorContext(r.Context(), "Failed to unmarshal checkout.session.completed", "error", err) + return nil + } + + orgID := sess.ClientReferenceID + if orgID == "" && sess.Customer != nil { + id, err := h.DB.GetOrganisationIDByStripeCustomerID(r.Context(), sess.Customer.ID) + if err != nil { + if errors.Is(err, db.ErrOrganisationNotFound) { + logger.WarnContext(logging.NoCapture(r.Context()), "Unknown Stripe customer — ACKing event", "customer_id", sess.Customer.ID) + return nil + } + logger.ErrorContext(r.Context(), "Cannot resolve organisation from Stripe customer", "error", err, "customer_id", sess.Customer.ID) + return fmt.Errorf("resolve organisation: %w", err) + } + orgID = id + } + if orgID == "" { + logger.ErrorContext(r.Context(), "checkout.session.completed: no organisation ID found — skipping") + return nil + } + + if sess.Customer != nil { + if err := h.DB.SetStripeCustomerID(r.Context(), orgID, sess.Customer.ID); err != nil { + logger.ErrorContext(r.Context(), "Failed to store Stripe customer ID", "error", err, "org_id", orgID) + return fmt.Errorf("set stripe customer id: %w", err) + } + } + + if sess.Subscription == nil { + return nil + } + subID := sess.Subscription.ID + if err := h.DB.SetStripeSubscriptionID(r.Context(), orgID, subID); err != nil { + logger.ErrorContext(r.Context(), "Failed to store Stripe subscription ID", "error", err, "org_id", orgID) + return fmt.Errorf("set stripe subscription id: %w", err) + } + + // The subscription in checkout.session.completed is not expanded — + // fetch it directly to get the line items and price ID. + subFetchParams := &stripe.SubscriptionParams{} + subFetchParams.Context = r.Context() + sub, err := stripesubscription.Get(subID, subFetchParams) + if err != nil { + logger.ErrorContext(r.Context(), "Failed to fetch subscription from Stripe", "error", err, "subscription_id", subID) + return fmt.Errorf("fetch subscription: %w", err) + } + + if len(sub.Items.Data) == 0 { + logger.ErrorContext(r.Context(), "Subscription has no line items — cannot activate plan", "subscription_id", subID) + return nil + } + + if sub.Items.Data[0].Price == nil { + logger.ErrorContext(r.Context(), "Subscription line item has no price — cannot activate plan", "subscription_id", subID) + return nil + } + + priceID := sub.Items.Data[0].Price.ID + plan, err := h.DB.GetPlanByStripePriceID(r.Context(), priceID) + if err != nil { + if errors.Is(err, db.ErrPlanNotFound) { + logger.WarnContext(logging.NoCapture(r.Context()), "Stripe price has no matching local plan — ACKing event", "price_id", priceID) + return nil + } + logger.ErrorContext(r.Context(), "Cannot resolve plan from Stripe price", "error", err, "price_id", priceID) + return fmt.Errorf("resolve plan: %w", err) + } + if err := h.DB.SetOrganisationPlan(r.Context(), orgID, plan.ID); err != nil { + logger.ErrorContext(r.Context(), "Failed to update organisation plan", "error", err, "org_id", orgID, "plan_id", plan.ID) + return fmt.Errorf("set organisation plan: %w", err) + } + logger.InfoContext(r.Context(), "Organisation plan activated via checkout", "org_id", orgID, "plan", plan.Name) + return nil +} + +func (h *Handler) handleSubscriptionUpdated(r *http.Request, event stripe.Event, logger *logging.Logger) error { + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + logger.ErrorContext(r.Context(), "Failed to unmarshal customer.subscription.updated", "error", err) + return nil + } + + if sub.Customer == nil { + logger.ErrorContext(r.Context(), "subscription.updated: missing customer — skipping") + return nil + } + + orgID, err := h.DB.GetOrganisationIDByStripeCustomerID(r.Context(), sub.Customer.ID) + if err != nil { + if errors.Is(err, db.ErrOrganisationNotFound) { + logger.WarnContext(logging.NoCapture(r.Context()), "Unknown Stripe customer — ACKing event", "customer_id", sub.Customer.ID) + return nil + } + logger.ErrorContext(r.Context(), "Cannot resolve organisation", "error", err, "customer_id", sub.Customer.ID) + return fmt.Errorf("resolve organisation: %w", err) + } + + // Only act on events for the org's current subscription. Stripe events can + // arrive late or out-of-order (e.g. an update for a long-canceled sub), + // and adopting whichever event lands first as the source of truth would + // reopen the very stale-event problem this guard exists to prevent. + // + // When no sub is stored, ignore the event entirely. Empty state means + // either we never observed checkout.session.completed (which is the + // authoritative seeder of stripe_subscription_id) or the user has already + // cancelled. BillingCheckout's defensive orphan check (billing.go) heals + // state on the next user action by listing Stripe subs and adopting the + // active one — only then is the ID trustworthy. + storedSubID, err := h.DB.GetStripeSubscriptionID(r.Context(), orgID) + if err != nil { + return fmt.Errorf("fetch stored subscription id: %w", err) + } + if storedSubID == "" { + logger.WarnContext(logging.NoCapture(r.Context()), "Ignoring subscription.updated — no current subscription stored", "org_id", orgID, "event_subscription_id", sub.ID) + return nil + } + if storedSubID != sub.ID { + logger.WarnContext(logging.NoCapture(r.Context()), "Ignoring subscription.updated for non-current subscription", "org_id", orgID, "event_subscription_id", sub.ID, "stored_subscription_id", storedSubID) + return nil + } + + // Re-fetch the subscription from Stripe before reading the price. Stripe + // doesn't guarantee webhook delivery order — a stale subscription.updated + // can arrive after a newer one. Acting on the payload alone risks + // overwriting the plan with an old price; the live Stripe object is the + // only authoritative source. + fetchParams := &stripe.SubscriptionParams{} + fetchParams.Context = r.Context() + freshSub, err := stripesubscription.Get(sub.ID, fetchParams) + if err != nil { + logger.ErrorContext(r.Context(), "Failed to refetch subscription from Stripe", "error", err, "subscription_id", sub.ID) + return fmt.Errorf("refetch subscription: %w", err) + } + + if freshSub.Items == nil || len(freshSub.Items.Data) == 0 { + logger.WarnContext(r.Context(), "subscription.updated: no line items on refreshed subscription — skipping plan update", "org_id", orgID) + return nil + } + + if freshSub.Items.Data[0].Price == nil { + logger.WarnContext(r.Context(), "subscription.updated: no price on refreshed line item — skipping plan update", "org_id", orgID) + return nil + } + + priceID := freshSub.Items.Data[0].Price.ID + plan, err := h.DB.GetPlanByStripePriceID(r.Context(), priceID) + if err != nil { + if errors.Is(err, db.ErrPlanNotFound) { + logger.WarnContext(logging.NoCapture(r.Context()), "Stripe price has no matching local plan — ACKing event", "price_id", priceID) + return nil + } + logger.ErrorContext(r.Context(), "Cannot resolve plan from Stripe price", "error", err, "price_id", priceID) + return fmt.Errorf("resolve plan: %w", err) + } + + if err := h.DB.SetOrganisationPlan(r.Context(), orgID, plan.ID); err != nil { + logger.ErrorContext(r.Context(), "Failed to update organisation plan", "error", err, "org_id", orgID, "plan_id", plan.ID) + return fmt.Errorf("set organisation plan: %w", err) + } + logger.InfoContext(r.Context(), "Organisation plan updated via subscription change", "org_id", orgID, "plan", plan.Name) + return nil +} + +func (h *Handler) handleSubscriptionDeleted(r *http.Request, event stripe.Event, logger *logging.Logger) error { + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + logger.ErrorContext(r.Context(), "Failed to unmarshal customer.subscription.deleted", "error", err) + return nil + } + + if sub.Customer == nil { + logger.ErrorContext(r.Context(), "subscription.deleted: missing customer — skipping") + return nil + } + + orgID, err := h.DB.GetOrganisationIDByStripeCustomerID(r.Context(), sub.Customer.ID) + if err != nil { + if errors.Is(err, db.ErrOrganisationNotFound) { + logger.WarnContext(logging.NoCapture(r.Context()), "Unknown Stripe customer — ACKing event", "customer_id", sub.Customer.ID) + return nil + } + logger.ErrorContext(r.Context(), "Cannot resolve organisation", "error", err, "customer_id", sub.Customer.ID) + return fmt.Errorf("resolve organisation: %w", err) + } + + // Only act on events for the org's current subscription. Same rationale + // as handleSubscriptionUpdated — a delete on a zombie sub mustn't + // downgrade an org whose real paid sub is healthy. + storedSubID, err := h.DB.GetStripeSubscriptionID(r.Context(), orgID) + if err != nil { + return fmt.Errorf("fetch stored subscription id: %w", err) + } + if storedSubID == "" { + logger.WarnContext(logging.NoCapture(r.Context()), "Ignoring subscription.deleted — no current subscription stored", "org_id", orgID, "event_subscription_id", sub.ID) + return nil + } + if storedSubID != sub.ID { + logger.WarnContext(logging.NoCapture(r.Context()), "Ignoring subscription.deleted for non-current subscription", "org_id", orgID, "event_subscription_id", sub.ID, "stored_subscription_id", storedSubID) + return nil + } + + freePlanID, err := h.DB.GetFreePlanID(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "Failed to fetch free plan ID for subscription cancellation", "error", err) + return fmt.Errorf("fetch free plan: %w", err) + } + + if err := h.DB.SetOrganisationPlan(r.Context(), orgID, freePlanID); err != nil { + logger.ErrorContext(r.Context(), "Failed to revert organisation to free plan", "error", err, "org_id", orgID) + return fmt.Errorf("revert to free plan: %w", err) + } + // Clear the stored subscription ID so a future Checkout creates a fresh + // subscription rather than tripping the duplicate-subscription guard. + if err := h.DB.SetStripeSubscriptionID(r.Context(), orgID, ""); err != nil { + logger.ErrorContext(r.Context(), "Failed to clear Stripe subscription ID after cancellation", "error", err, "org_id", orgID) + return fmt.Errorf("clear stripe subscription id: %w", err) + } + logger.InfoContext(r.Context(), "Organisation reverted to free plan — subscription cancelled", "org_id", orgID) + return nil +} + +func (h *Handler) handleInvoicePaymentFailed(r *http.Request, event stripe.Event, logger *logging.Logger) { + var inv stripe.Invoice + if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { + logger.ErrorContext(r.Context(), "Failed to unmarshal invoice.payment_failed", "error", err) + return + } + customerID := "" + if inv.Customer != nil { + customerID = inv.Customer.ID + } + // Customer-driven payment issue, not a system fault — keep out of Sentry. + logger.WarnContext(logging.NoCapture(r.Context()), "Stripe invoice payment failed — Stripe will retry per dunning schedule", "invoice_id", inv.ID, "customer_id", customerID) +} diff --git a/internal/db/db.go b/internal/db/db.go index 2187ad969..375e25f8f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "net/url" "os" @@ -19,6 +20,16 @@ import ( var dbLog = logging.Component("db") +// Sentinel errors for missing resources. Callers can branch on these via +// errors.Is to distinguish "this thing doesn't exist" from a transient DB +// failure. Used by Stripe webhook handlers to ACK events Stripe shouldn't +// retry (unknown customer, unknown price) versus surfacing 5xx for real +// faults. +var ( + ErrOrganisationNotFound = errors.New("organisation not found") + ErrPlanNotFound = errors.New("plan not found") +) + const ( // defaultConnMaxLifetime controls how long a connection can live before // being recycled. Shorter values improve compatibility with pgBouncer. diff --git a/internal/db/organisations.go b/internal/db/organisations.go index df24dd0c8..c668be937 100644 --- a/internal/db/organisations.go +++ b/internal/db/organisations.go @@ -424,6 +424,131 @@ func (db *DB) GetOrganisationPlanID(ctx context.Context, organisationID string) return planID, nil } +// SetStripeCustomerID stores the Stripe customer ID on an organisation. +func (db *DB) SetStripeCustomerID(ctx context.Context, organisationID, customerID string) error { + _, err := db.client.ExecContext(ctx, + `UPDATE organisations SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`, + organisationID, customerID, + ) + if err != nil { + return fmt.Errorf("failed to set stripe customer id: %w", err) + } + return nil +} + +// GetStripeCustomerID returns the Stripe customer ID for an organisation, or "" if unset. +func (db *DB) GetStripeCustomerID(ctx context.Context, organisationID string) (string, error) { + var id sql.NullString + err := db.client.QueryRowContext(ctx, + `SELECT stripe_customer_id FROM organisations WHERE id = $1`, + organisationID, + ).Scan(&id) + if err != nil { + if err == sql.ErrNoRows { + return "", fmt.Errorf("organisation not found") + } + return "", fmt.Errorf("failed to fetch stripe customer id: %w", err) + } + if !id.Valid { + return "", nil + } + return id.String, nil +} + +// GetOrganisationIDByStripeCustomerID returns the organisation ID for a Stripe customer. +// Returns ErrOrganisationNotFound (sentinel) when no row matches — callers can +// use errors.Is to ACK Stripe webhooks for unknown customers without retries. +func (db *DB) GetOrganisationIDByStripeCustomerID(ctx context.Context, customerID string) (string, error) { + var orgID string + err := db.client.QueryRowContext(ctx, + `SELECT id FROM organisations WHERE stripe_customer_id = $1`, + customerID, + ).Scan(&orgID) + if err != nil { + if err == sql.ErrNoRows { + return "", fmt.Errorf("no organisation for stripe customer %s: %w", customerID, ErrOrganisationNotFound) + } + return "", fmt.Errorf("failed to lookup organisation by stripe customer: %w", err) + } + return orgID, nil +} + +// SetStripeSubscriptionID stores the Stripe subscription ID on an organisation. +// Pass "" to clear the column (stored as NULL) — used when a subscription is +// cancelled so a future Checkout can create a fresh subscription. +func (db *DB) SetStripeSubscriptionID(ctx context.Context, organisationID, subscriptionID string) error { + var v sql.NullString + if subscriptionID != "" { + v = sql.NullString{String: subscriptionID, Valid: true} + } + _, err := db.client.ExecContext(ctx, + `UPDATE organisations SET stripe_subscription_id = $2, updated_at = NOW() WHERE id = $1`, + organisationID, v, + ) + if err != nil { + return fmt.Errorf("failed to set stripe subscription id: %w", err) + } + return nil +} + +// GetStripeSubscriptionID returns the Stripe subscription ID for an organisation, +// or "" if the org has no active subscription stored. +func (db *DB) GetStripeSubscriptionID(ctx context.Context, organisationID string) (string, error) { + var id sql.NullString + err := db.client.QueryRowContext(ctx, + `SELECT stripe_subscription_id FROM organisations WHERE id = $1`, + organisationID, + ).Scan(&id) + if err != nil { + if err == sql.ErrNoRows { + return "", fmt.Errorf("organisation not found") + } + return "", fmt.Errorf("failed to fetch stripe subscription id: %w", err) + } + if !id.Valid { + return "", nil + } + return id.String, nil +} + +// GetPlanByStripePriceID returns the plan with the given Stripe Price ID. +// Returns ErrPlanNotFound (sentinel) when no row matches — webhook handlers +// use this to ACK events for prices that aren't configured locally. +func (db *DB) GetPlanByStripePriceID(ctx context.Context, priceID string) (*Plan, error) { + var p Plan + err := db.client.QueryRowContext(ctx, + `SELECT id, name, display_name, daily_page_limit, monthly_price_cents, + is_active, sort_order, created_at, COALESCE(stripe_price_id, '') + FROM plans WHERE stripe_price_id = $1`, + priceID, + ).Scan( + &p.ID, &p.Name, &p.DisplayName, &p.DailyPageLimit, &p.MonthlyPriceCents, + &p.IsActive, &p.SortOrder, &p.CreatedAt, &p.StripePriceID, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no plan for stripe price %s: %w", priceID, ErrPlanNotFound) + } + return nil, fmt.Errorf("failed to lookup plan by stripe price id: %w", err) + } + return &p, nil +} + +// GetFreePlanID returns the plan ID for the 'free' plan. +func (db *DB) GetFreePlanID(ctx context.Context) (string, error) { + var id string + err := db.client.QueryRowContext(ctx, + `SELECT id FROM plans WHERE name = 'free' AND is_active = true LIMIT 1`, + ).Scan(&id) + if err != nil { + if err == sql.ErrNoRows { + return "", fmt.Errorf("free plan not found") + } + return "", fmt.Errorf("failed to fetch free plan id: %w", err) + } + return id, nil +} + // ListDailyUsage returns daily usage rows for an organisation within a date range. func (db *DB) ListDailyUsage(ctx context.Context, organisationID string, startDate, endDate time.Time) ([]DailyUsageEntry, error) { query := ` diff --git a/internal/db/users.go b/internal/db/users.go index 3dddef437..3ee98b62f 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -688,19 +688,31 @@ func (db *DB) GetOrganisationUsageStats(ctx context.Context, orgID string) (*Usa stats.UsagePercentage = float64(stats.DailyUsed) / float64(stats.DailyLimit) * 100 } + // Check whether this org has a Stripe customer on file. A real DB error + // here must propagate — silently coercing to false would make billing + // actions look unavailable for customers who actually have Stripe set up. + var customerID sql.NullString + if err := db.client.QueryRowContext(ctx, + `SELECT stripe_customer_id FROM organisations WHERE id = $1`, orgID, + ).Scan(&customerID); err != nil { + return nil, fmt.Errorf("failed to fetch stripe customer id: %w", err) + } + stats.HasStripeCustomer = customerID.Valid && customerID.String != "" + return &stats, nil } // UsageStats represents current usage statistics for an organisation type UsageStats struct { - DailyLimit int `json:"daily_limit"` - DailyUsed int `json:"daily_used"` - DailyRemaining int `json:"daily_remaining"` - UsagePercentage float64 `json:"usage_percentage"` - PlanID string `json:"plan_id"` - PlanName string `json:"plan_name"` - PlanDisplayName string `json:"plan_display_name"` - ResetsAt time.Time `json:"resets_at"` + DailyLimit int `json:"daily_limit"` + DailyUsed int `json:"daily_used"` + DailyRemaining int `json:"daily_remaining"` + UsagePercentage float64 `json:"usage_percentage"` + PlanID string `json:"plan_id"` + PlanName string `json:"plan_name"` + PlanDisplayName string `json:"plan_display_name"` + ResetsAt time.Time `json:"resets_at"` + HasStripeCustomer bool `json:"has_stripe_customer"` } // Plan represents a subscription tier @@ -713,13 +725,14 @@ type Plan struct { IsActive bool `json:"is_active"` SortOrder int `json:"sort_order"` CreatedAt time.Time `json:"created_at"` + StripePriceID string `json:"-"` // Internal — never returned to clients } // GetActivePlans returns all active subscription plans func (db *DB) GetActivePlans(ctx context.Context) ([]Plan, error) { query := ` SELECT id, name, display_name, daily_page_limit, monthly_price_cents, - is_active, sort_order, created_at + is_active, sort_order, created_at, COALESCE(stripe_price_id, '') FROM plans WHERE is_active = true ORDER BY sort_order @@ -736,7 +749,7 @@ func (db *DB) GetActivePlans(ctx context.Context) ([]Plan, error) { var p Plan if err := rows.Scan( &p.ID, &p.Name, &p.DisplayName, &p.DailyPageLimit, &p.MonthlyPriceCents, - &p.IsActive, &p.SortOrder, &p.CreatedAt, + &p.IsActive, &p.SortOrder, &p.CreatedAt, &p.StripePriceID, ); err != nil { return nil, fmt.Errorf("failed to scan plan: %w", err) } diff --git a/settings.html b/settings.html index ff67edaee..b7e94878e 100644 --- a/settings.html +++ b/settings.html @@ -372,31 +372,27 @@

Plans

Billing

- Update payment methods and view invoices. Admin-only. + Manage your subscription and payment methods. Admin-only.

- Payment methods -
- Paddle connection is required to manage billing. + Payment methods & invoices +
+ Upgrade to a paid plan to manage billing.
-
- -
-
-
- Invoices -
No invoices available yet.
-
-
-
diff --git a/supabase/migrations/20260428210000_add_stripe_billing.sql b/supabase/migrations/20260428210000_add_stripe_billing.sql new file mode 100644 index 000000000..5ac6e2101 --- /dev/null +++ b/supabase/migrations/20260428210000_add_stripe_billing.sql @@ -0,0 +1,13 @@ +-- Add Stripe Price ID mapping to plans table. +-- Populated manually after creating Prices in Stripe Dashboard. +ALTER TABLE plans ADD COLUMN IF NOT EXISTS stripe_price_id TEXT; + +-- Add Stripe customer and subscription tracking to organisations. +ALTER TABLE organisations + ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT, + ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT; + +-- Fast webhook lookup by Stripe customer ID. +CREATE INDEX IF NOT EXISTS idx_organisations_stripe_customer_id + ON organisations(stripe_customer_id) + WHERE stripe_customer_id IS NOT NULL; diff --git a/supabase/migrations/20260428210001_update_plans_to_new_tiers.sql b/supabase/migrations/20260428210001_update_plans_to_new_tiers.sql new file mode 100644 index 000000000..cad6c6e93 --- /dev/null +++ b/supabase/migrations/20260428210001_update_plans_to_new_tiers.sql @@ -0,0 +1,86 @@ +-- Migrate plans table to new tier structure. +-- +-- Old tiers (now replaced): +-- free (500/day, $0), starter (2000/day, $50), pro (5000/day, $80), +-- business (10000/day, $150), enterprise (100000/day, $400) +-- +-- New tiers: +-- free (500/day, $0), starter (200/day, $19), plus (1000/day, $49), +-- pro (10000/day, $149), ultra (100000/day, $399), max (500000/day, $849) +-- +-- No paying customers existed before Stripe integration, so updating +-- existing plan values directly is safe. + +-- Update 'starter': new page limit and price. +UPDATE plans +SET display_name = 'Starter', + daily_page_limit = 200, + monthly_price_cents = 1900, + sort_order = 10, + stripe_price_id = 'price_1TJAjJS2RiCh0hZBfgrnoI0C', + updated_at = NOW() +WHERE name = 'starter'; + +-- Update 'pro': new page limit and price. +UPDATE plans +SET display_name = 'Pro', + daily_page_limit = 10000, + monthly_price_cents = 14900, + sort_order = 30, + stripe_price_id = 'price_1TJAm4S2RiCh0hZBK8u1HbqG', + updated_at = NOW() +WHERE name = 'pro'; + +-- Deactivate old tiers replaced by new ones. +UPDATE plans +SET is_active = false, + updated_at = NOW() +WHERE name IN ('business', 'enterprise'); + +-- Insert new 'plus' tier. +INSERT INTO plans (name, display_name, daily_page_limit, monthly_price_cents, sort_order, stripe_price_id) +VALUES ('plus', 'Plus', 1000, 4900, 20, 'price_1TJAwBS2RiCh0hZBeS17btD7') +ON CONFLICT (name) DO UPDATE + SET display_name = EXCLUDED.display_name, + daily_page_limit = EXCLUDED.daily_page_limit, + monthly_price_cents = EXCLUDED.monthly_price_cents, + sort_order = EXCLUDED.sort_order, + stripe_price_id = EXCLUDED.stripe_price_id, + is_active = true, + updated_at = NOW(); + +-- Insert new 'ultra' tier. +INSERT INTO plans (name, display_name, daily_page_limit, monthly_price_cents, sort_order, stripe_price_id) +VALUES ('ultra', 'Ultra', 100000, 39900, 40, 'price_1TJAx1S2RiCh0hZBRLcnk0zD') +ON CONFLICT (name) DO UPDATE + SET display_name = EXCLUDED.display_name, + daily_page_limit = EXCLUDED.daily_page_limit, + monthly_price_cents = EXCLUDED.monthly_price_cents, + sort_order = EXCLUDED.sort_order, + stripe_price_id = EXCLUDED.stripe_price_id, + is_active = true, + updated_at = NOW(); + +-- Insert new 'max' tier. +INSERT INTO plans (name, display_name, daily_page_limit, monthly_price_cents, sort_order, stripe_price_id) +VALUES ('max', 'Max', 500000, 84900, 50, 'price_1TJB0IS2RiCh0hZBwfOB0aoE') +ON CONFLICT (name) DO UPDATE + SET display_name = EXCLUDED.display_name, + daily_page_limit = EXCLUDED.daily_page_limit, + monthly_price_cents = EXCLUDED.monthly_price_cents, + sort_order = EXCLUDED.sort_order, + stripe_price_id = EXCLUDED.stripe_price_id, + is_active = true, + updated_at = NOW(); + +-- Move any orgs on deactivated plans to their nearest replacement. +-- (No paying customers yet, but handles dev/test data cleanly.) +UPDATE organisations +SET plan_id = (SELECT id FROM plans WHERE name = 'pro'), + updated_at = NOW() +WHERE plan_id = (SELECT id FROM plans WHERE name = 'business'); + +UPDATE organisations +SET plan_id = (SELECT id FROM plans WHERE name = 'ultra'), + updated_at = NOW() +WHERE plan_id = (SELECT id FROM plans WHERE name = 'enterprise'); diff --git a/supabase/seed.sql b/supabase/seed.sql index b7e2def3b..4c33028fc 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -52,14 +52,25 @@ VALUES ('00000000-0000-0000-0000-000000000002', 'dev@example.com', '00000000-0000-0000-0000-000000000002', '{"sub": "00000000-0000-0000-0000-000000000002", "email": "dev@example.com", "email_verified": true}', 'email', NOW(), NOW(), NOW()) ON CONFLICT (provider_id, provider) DO NOTHING; +-- ============================================================================= +-- public.plans — Stripe sandbox price IDs (review/local dev only). +-- The migration sets live-mode price IDs for prod; this seed overrides them +-- with sandbox IDs so non-prod environments check out against sandbox products. +-- ============================================================================= +UPDATE plans SET stripe_price_id = 'price_1TRJTFS2RiCh0hZBTaF9EjA9' WHERE name = 'starter'; +UPDATE plans SET stripe_price_id = 'price_1TRImSS2RiCh0hZBRH8ZVXvD' WHERE name = 'plus'; +UPDATE plans SET stripe_price_id = 'price_1TRImtS2RiCh0hZBXrKCkR4n' WHERE name = 'pro'; +UPDATE plans SET stripe_price_id = 'price_1TRIm5S2RiCh0hZBxIILpcQn' WHERE name = 'ultra'; +UPDATE plans SET stripe_price_id = 'price_1TRIlCS2RiCh0hZBSg0h1CkX' WHERE name = 'max'; + -- ============================================================================= -- public.organisations -- ============================================================================= INSERT INTO public.organisations (id, name, plan_id, created_at, updated_at) VALUES - ('96f7546c-47ea-41f8-a3a3-46b4deb84105', 'Personal Organisation', (SELECT id FROM plans WHERE name = 'enterprise'), '2025-11-02 00:11:21.520651+00', '2025-11-02 00:11:21.520651+00'), - ('2cfb393e-03e3-4acc-b19a-0958e6332060', 'Harvey', (SELECT id FROM plans WHERE name = 'enterprise'), '2026-01-02 09:38:36.934168+00', '2026-01-02 09:38:36.934168+00'), - ('da324afb-ce97-4814-975e-b6203cb51b0a', 'Merry People', (SELECT id FROM plans WHERE name = 'enterprise'), '2026-01-02 09:38:43.358225+00', '2026-01-02 09:38:43.358225+00') + ('96f7546c-47ea-41f8-a3a3-46b4deb84105', 'Personal Organisation', (SELECT id FROM plans WHERE name = 'ultra'), '2025-11-02 00:11:21.520651+00', '2025-11-02 00:11:21.520651+00'), + ('2cfb393e-03e3-4acc-b19a-0958e6332060', 'Harvey', (SELECT id FROM plans WHERE name = 'ultra'), '2026-01-02 09:38:36.934168+00', '2026-01-02 09:38:36.934168+00'), + ('da324afb-ce97-4814-975e-b6203cb51b0a', 'Merry People', (SELECT id FROM plans WHERE name = 'ultra'), '2026-01-02 09:38:43.358225+00', '2026-01-02 09:38:43.358225+00') ON CONFLICT (id) DO UPDATE SET plan_id = EXCLUDED.plan_id; -- ============================================================================= diff --git a/web/static/app/lib/settings/plans.js b/web/static/app/lib/settings/plans.js index 2ce08acb3..56298dd39 100644 --- a/web/static/app/lib/settings/plans.js +++ b/web/static/app/lib/settings/plans.js @@ -5,13 +5,35 @@ * Surface-agnostic: all render/action functions accept a container element. */ -import { get, put } from "/app/lib/api-client.js"; +import { get, post } from "/app/lib/api-client.js"; import { showToast as _showToast } from "/app/components/hover-toast.js"; function toast(variant, message) { _showToast(message, { variant }); } +// ── Usage cache ──────────────────────────────────────────────────────────────── + +let _cachedUsage = null; +let _cachedUsageTs = 0; +const USAGE_CACHE_TTL = 30_000; + +async function getUsage() { + const now = Date.now(); + if (_cachedUsage && now - _cachedUsageTs < USAGE_CACHE_TTL) { + return _cachedUsage; + } + const response = await get("/v1/usage"); + _cachedUsage = response; + _cachedUsageTs = now; + return response; +} + +export function invalidateUsageCache() { + _cachedUsage = null; + _cachedUsageTs = 0; +} + // ── Plans & Usage ────────────────────────────────────────────────────────────── /** @@ -32,7 +54,7 @@ export async function loadPlansAndUsage(container, options = {}) { try { const [usageResponse, plansResponse] = await Promise.all([ - get("/v1/usage"), + getUsage(), get("/v1/plans"), ]); @@ -96,11 +118,19 @@ export async function loadPlansAndUsage(container, options = {}) { } else if (role !== "admin") { actionBtn.textContent = "Admin only"; actionBtn.disabled = true; + } else if (plan.monthly_price_cents > 0) { + actionBtn.textContent = "Upgrade"; + actionBtn.disabled = false; + actionBtn.addEventListener("click", () => + startCheckout(plan.id, actionBtn) + ); } else { - actionBtn.textContent = "Switch plan"; + // Downgrading to free — cancel the Stripe subscription via the + // billing backend (so the customer stops being charged). + actionBtn.textContent = "Switch to Free"; actionBtn.disabled = false; actionBtn.addEventListener("click", () => - switchPlan(plan.id, container, options) + switchToFree(container, options) ); } } @@ -114,19 +144,117 @@ export async function loadPlansAndUsage(container, options = {}) { } } -async function switchPlan(planId, container, options = {}) { - if (!planId) return; - if (!confirm("Switch to this plan?")) return; +// Switch to free — schedules cancellation of the active Stripe subscription +// at the end of the current billing period. The customer keeps paid features +// until then, then automatically downgrades when Stripe fires +// customer.subscription.deleted. +async function switchToFree(container, options = {}) { + if ( + !confirm( + "Cancel your subscription? You'll keep your current plan until the end of the billing period, then revert to free." + ) + ) { + return; + } try { - await put("/v1/organisations/plan", { plan_id: planId }); - toast("success", "Plan updated"); + const response = await post("/v1/billing/cancel", {}); + invalidateUsageCache(); + const periodEnd = response?.period_end; + let msg = "Subscription cancellation scheduled"; + if (typeof periodEnd === "number" && periodEnd > 0) { + const date = new Date(periodEnd * 1000).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); + msg = `Plan stays active until ${date}, then reverts to free.`; + } + toast("success", msg); await loadPlansAndUsage(container, options); window.GNHQuota?.refresh(); } catch (err) { - console.error("Failed to switch plan:", err); - toast("error", "Failed to switch plan"); + console.error("Failed to cancel subscription:", err); + const msg = + err?.body?.message || "Failed to cancel subscription — please try again"; + toast("error", msg); + } +} + +async function startCheckout(planId, btn) { + if (!planId) return; + + if (btn) { + btn.disabled = true; + btn.textContent = "Loading…"; } + try { + const response = await post("/v1/billing/checkout", { plan_id: planId }); + if (response.url) { + window.location.href = response.url; + } + } catch (err) { + console.error("Failed to start checkout:", err); + // Surface server messages for known cases (e.g. 409 — already subscribed). + // Stripe Checkout in subscription mode always creates a new sub, so the + // server rejects a second Checkout when one is already active. + const msg = + err?.status === 409 && err?.body?.message + ? err.body.message + : "Failed to open checkout — please try again"; + toast("error", msg); + if (btn) { + btn.disabled = false; + btn.textContent = "Upgrade"; + } + } +} + +/** + * Initialise the billing section — fetch current billing state and wire + * up the "Manage billing" button. Reads has_stripe_customer from /v1/usage. + */ +export async function loadBillingSection() { + const btn = document.getElementById("manageBillingBtn"); + const status = document.getElementById("billingStatus"); + if (!btn) return; + + let hasStripeCustomer = false; + try { + const usageResponse = await getUsage(); + hasStripeCustomer = !!usageResponse?.usage?.has_stripe_customer; + } catch (err) { + console.error("Failed to fetch billing status:", err); + } + + if (!hasStripeCustomer) { + // No subscription yet — keep button disabled. + return; + } + + if (status) { + status.textContent = + "Manage your subscription, update payment methods, and view invoices."; + } + btn.disabled = false; + // Replace the button to avoid duplicate listeners on refresh. + const fresh = btn.cloneNode(true); + btn.replaceWith(fresh); + fresh.addEventListener("click", async () => { + fresh.disabled = true; + fresh.textContent = "Opening…"; + try { + const response = await post("/v1/billing/portal", {}); + if (response.url) { + window.location.href = response.url; + } + } catch (err) { + console.error("Failed to open billing portal:", err); + toast("error", "Failed to open billing portal — please try again"); + fresh.disabled = false; + fresh.textContent = "Manage billing"; + } + }); } // ── Usage history ────────────────────────────────────────────────────────────── diff --git a/web/static/app/pages/settings.js b/web/static/app/pages/settings.js index 12617ec24..b232b0171 100644 --- a/web/static/app/pages/settings.js +++ b/web/static/app/pages/settings.js @@ -23,6 +23,8 @@ import { import { loadPlansAndUsage, loadUsageHistory, + loadBillingSection, + invalidateUsageCache, } from "/app/lib/settings/plans.js"; import { loadSchedules, @@ -230,9 +232,41 @@ async function handleInviteToken() { } } +// ── Billing return ───────────────────────────────────────────────────────────── + +// Reads ?billing=success|cancelled set by the Stripe Checkout redirect, shows +// a toast, busts the usage cache so loadPlansAndUsage picks up fresh state +// (new plan, has_stripe_customer flip), and strips the query param so a reload +// does not re-toast. +function handleBillingRedirect() { + const params = new URLSearchParams(window.location.search); + const billing = params.get("billing"); + if (!billing) return; + + if (billing === "success") { + invalidateUsageCache(); + toast("success", "Subscription activated"); + } else if (billing === "cancelled") { + toast("warning", "Checkout cancelled"); + } + + params.delete("billing"); + const cleanQuery = params.toString(); + const cleanUrl = + window.location.pathname + + (cleanQuery ? "?" + cleanQuery : "") + + window.location.hash; + window.history.replaceState({}, "", cleanUrl); +} + // ── Refresh (called on org-switch) ────────────────────────────────────────────── async function refreshSections() { + // refreshSections runs on org-switch (via gnh:org-switched). The usage + // cache in plans.js is keyed by the cache module, not by org, so we must + // bust it here or the new org's plan tab can render the previous org's + // plan/has_stripe_customer state for up to USAGE_CACHE_TTL. + invalidateUsageCache(); const c = getContainers(); const teamState = getTeamState(); try { @@ -243,6 +277,7 @@ async function refreshSections() { currentUserRole: teamState.currentUserRole, }); await loadUsageHistory(c.plans); + await loadBillingSection(); await loadSchedules(c.schedules); await loadSlackConnections(); await loadWebflowConnections(); @@ -293,6 +328,11 @@ async function init() { // Invite token handling. await handleInviteToken(); + // Stripe Checkout return — show toast, bust the usage cache so the new + // plan/Manage Billing state renders on the first loadPlansAndUsage below, + // then strip the query param so reloads don't re-toast. + handleBillingRedirect(); + const c = getContainers(); // Wire up event listeners for section modules. @@ -313,6 +353,7 @@ async function init() { currentUserRole: teamState.currentUserRole, }); await loadUsageHistory(c.plans); + await loadBillingSection(); await loadSchedules(c.schedules); } catch (err) { console.error("Failed to initialise settings sections:", err);