Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
aa165fe
Add Stripe billing integration
simonsmallchua Apr 6, 2026
cdc6788
Update plans to new tier structure
simonsmallchua Apr 6, 2026
ef90979
Fix Stripe webhook subscription fetch
simonsmallchua Apr 6, 2026
a25bac4
Add 1Password local dev secrets
simonsmallchua Apr 6, 2026
69e8a05
Fix Stripe billing critical/major review issues
simonsmallchua Apr 6, 2026
c682a24
Fix Stripe nil guards and usage request dedup
simonsmallchua Apr 6, 2026
52760e1
Promote stripe-go to direct dependency
simonsmallchua Apr 6, 2026
0781500
Update changelog for Stripe billing
simonsmallchua Apr 6, 2026
1c97a4f
Resolve imports and deps post-rebase
simonsmallchua Apr 28, 2026
4f3a75e
Return 5xx on transient Stripe webhook failures
simonsmallchua Apr 28, 2026
13dd662
Harden billing checkout against duplicates and PII
simonsmallchua Apr 28, 2026
cdd3829
Bust usage cache on plan switch
simonsmallchua Apr 28, 2026
33da653
Wire Stripe secrets into deploy and review-app pipelines
simonsmallchua Apr 28, 2026
81b0858
Move Stripe live keys into fly-setup composite
simonsmallchua Apr 28, 2026
192edb2
Restore corrected Stripe op:// paths in .env.op
simonsmallchua Apr 28, 2026
8e4c50c
Reorder Stripe migrations after main's tail
simonsmallchua Apr 28, 2026
c0487c0
Make Stripe migration idempotent
simonsmallchua Apr 28, 2026
7319f3b
Trigger Supabase Preview after migration log repair
simonsmallchua Apr 28, 2026
6e56740
Seed sandbox Stripe price IDs and fix orgs
simonsmallchua Apr 28, 2026
b8c17ae
Reload review app for updated Stripe webhook secret
simonsmallchua Apr 28, 2026
4ee25bb
Merge branch 'main' into worktree-calm-hugging-pebble
simonsmallchua Apr 29, 2026
83b8c1b
Reload review app for new Stripe webhook secret
simonsmallchua Apr 29, 2026
b7124fa
Move Stripe webhook route under /v1 prefix
simonsmallchua Apr 29, 2026
9085689
Tolerate API version drift on webhook events
simonsmallchua Apr 29, 2026
ad19781
Wire Stripe portal config ID into billing portal
simonsmallchua Apr 29, 2026
264e594
Redirect Stripe Checkout to plans tab with toast
simonsmallchua Apr 29, 2026
da4c375
Switch existing subscriptions in place via Stripe API
simonsmallchua Apr 29, 2026
9dfaa62
Use sandbox portal config for review apps
simonsmallchua Apr 30, 2026
1a72320
Cancel Stripe sub on Switch to Free, adopt orphan subs
simonsmallchua Apr 30, 2026
6adc64e
Cancel at period end so customers keep paid time
simonsmallchua Apr 30, 2026
c017443
Address CodeRabbit billing safety findings
simonsmallchua Apr 30, 2026
6a28b2b
ACK Stripe webhooks for unknown customer or price
simonsmallchua Apr 30, 2026
a615e2b
Adopt Stripe sub ID on first webhook for non-current guard
simonsmallchua Apr 30, 2026
38d8ba2
Refuse subscription events when no sub is stored
simonsmallchua Apr 30, 2026
54a06dc
Update changelog with hardening and switch flows
simonsmallchua Apr 30, 2026
52de11e
Switch Stripe code to slog logging with Sentry integration
simonsmallchua Apr 30, 2026
2f653bd
Refetch Stripe subscription on update webhook for ordering
simonsmallchua Apr 30, 2026
78f3e83
Sync plan_id immediately on in-place subscription update
simonsmallchua Apr 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions .env.op
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .fly/review_apps.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions .github/actions/fly-setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- uses: superfly/flyctl-actions/setup-flyctl@63da3ecc5e2793b98a3f2519b3d75d4f4c11cec2 # pinned

Expand All @@ -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
3 changes: 3 additions & 0 deletions .github/workflows/fly-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/review-apps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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" \
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.env
.env.*
!.env.example
!.env.op

# Dependencies
/vendor/
Expand Down
58 changes: 57 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<org>:<price>`),
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._

Expand Down
13 changes: 13 additions & 0 deletions cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Comment thread
simonsmallchua marked this conversation as resolved.
StripePortalConfigID: os.Getenv("STRIPE_PORTAL_CONFIG_ID"),
}

if config.FlightRecorderEnabled {
Expand Down Expand Up @@ -648,6 +656,11 @@ func main() {
brokerCleaner,
googleClientID,
googleClientSecret,
config.StripeSecretKey,
config.StripeWebhookSecret,
config.StripePublishableKey,
config.StripePortalConfigID,
getEnvWithDefault("SETTINGS_URL", ""),
)

mux := http.NewServeMux()
Expand Down
38 changes: 38 additions & 0 deletions dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=<whsec_...> 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..."
Expand Down
3 changes: 3 additions & 0 deletions docs/development/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading
Loading