Context
Three tiers via Stripe through @stacksjs/payments (read the stacks-payments skill): Free is implicit (no active subscription - loghq's model), Hobby and Pro are Stripe products defined in config/saas.ts and resolved by lookup key. Self-hosted installs leave STRIPE_* unset and billing stays inert.
Reference: loghq config/{payment,saas}.ts, app/Actions/Payment/CreateCheckoutAction.ts, app/Actions/StripeWebhook.ts, app/Actions/MeAction.ts, resources/views/pricing.stx.
Proposed plan matrix (final numbers are a product decision at implementation, keep the keys)
| Free | Hobby $9/mo, $90/yr | Pro $29/mo, $290/yr | |
|---|---|---|---|
| Projects | 1 | 3 | Unlimited |
| Events/month | 100k | 1M | 10M |
| Reports | 3 | Unlimited | Unlimited |
| Retention | 30 days | 12 months | 3 years |
| Members/project | 2 | 5 | Unlimited |
| Scheduled emails | - | Daily | Hourly-capable |
| Exports | CSV | CSV + XLSX | CSV + XLSX |
| Share links | Branded | Branded | Unbranded + embeds |
Lookup keys: reportshq_hobby_monthly, reportshq_hobby_yearly, reportshq_pro_monthly, reportshq_pro_yearly.
Scope
config/saas.tsproducts/pricing as above;config/payment.tswired to env-driven Stripe keys.- Checkout:
Payment.subscriptionCheckout()by lookup key from the pricing page (useMutationpattern like loghq); success/cancel routes; customer auto-created on first checkout (getOrCreateCustomer). - Webhook action syncing the local
subscriptionstable offusers.stripe_id(port loghq'sStripeWebhook.ts, extend for plan-change and payment-failure events); idempotent handling. - Plan resolution helper: one function answering "which tier is this user" from
Payment.hasActiveSubscription+ price lookup key, cached per request; consumed by #18 andMeAction-style session payloads. - Upgrade/downgrade via
Payment.changeSubscription(lookup key swap), cancel at period end; proration semantics documented. - Billing settings page: current plan, usage summary (from #18), invoices list, payment method management, cancel/resume - designed per #3, not a raw Stripe portal dump (Stripe-hosted portal link is fine as the escape hatch).
- Stripe test-mode fixtures + feature tests: checkout session creation, webhook lifecycle (created/updated/canceled/payment_failed), tier resolution, inert-when-unconfigured.
Acceptance
- In Stripe test mode: subscribe to Hobby, webhook lands, tier resolves Hobby, upgrade to Pro, cancel, tier decays to Free at period end (simulated clock). With
STRIPE_*unset, the app runs with everything Free and no errors.
Depends on #4. Blocks #18; feeds pricing page #19. Part of #1 (M6 Monetization). Ground rules: see #1.