Billing & Stripe Code Review — Consolidated Report

Date: 2026-02-06 Reviewed by: 4 parallel agents (Code Quality, Performance, Workflow/Errors, Edge Cases) Files reviewed: ~17 across src/lib/stripe.ts, src/config/pricing.ts, src/db/schema/subscriptions-schema.ts, src/actions/db/subscriptions-actions.ts, src/app/api/stripe/, src/app/api/webhooks/stripe/route.ts, src/components/upgrade/, src/lib/contexts/upgrade-modal-context.tsx, src/types/upgrade.ts, src/lib/hooks/use-upgrade-modal.ts


Critical — Fix Before Launch

1. Yearly billing toggle does nothing — always charges monthly

subscriptions-actions.ts:93 hardcodes STRIPE_IDS.prices[...].monthly. The billingPeriod state from BillingToggle is never passed through. A user selecting "Yearly ($83/mo billed annually)" gets charged $199/mo instead — a $1,000+/year overcharge.

Flagged by: Workflow, Edge Cases

2. past_due/unpaid subscriptions retain full paid features

auth.ts:94-113 sets planType from the DB regardless of subscription status. Feature gates in buses-actions.ts:31, projects-actions.ts:37, and upgrade-modal-context.tsx:69 only check planType, never status. A user whose payment fails keeps Professional access indefinitely.

Flagged by: Edge Cases, Workflow

3. No unique constraint on subscriptions per user — duplicate billing

subscriptions-schema.ts has no unique constraint on userId. The "already has subscription" check at subscriptions-actions.ts:79 only blocks status === "active" — users with past_due, trialing, incomplete, or paused can create new checkouts. Two-tab race conditions can also bypass it entirely.

Flagged by: Edge Cases, Workflow

4. Organization deletion doesn't cancel Stripe subscription

organizations-actions.ts:291-316 deletes the org but never calls stripe.subscriptions.cancel(). The Stripe subscription becomes orphaned and continues billing monthly with no way to manage it through the app.

Flagged by: Edge Cases

5. Dual pricing config creates dangerous drift

stripe.ts defines Pro at $89/mo; pricing.ts defines Professional at $199/mo. The webhook at route.ts:199-202 does fragile string mapping between "pro" and "professional". A Starter plan checkout produces metadata plan: "starter" which has no matching key in STRIPE_CONFIG.plans, causing the auto-created plan record to have null pricing.

Flagged by: All four agents

6. No URL validation on checkout redirects — open redirect

checkout/route.ts:16 and subscriptions-actions.ts:66-112 pass successUrl/cancelUrl directly to Stripe without domain validation. An attacker can set these to a phishing site.

Flagged by: Workflow, Edge Cases


Important — Should Fix Soon

7. Credit balance sync is not idempotent

route.ts:428-504 — if a duplicate subscription.created webhook arrives, the proportional credit adjustment math runs again with a different usage ratio, potentially granting extra credits or removing them.

Flagged by: Workflow

8. Member removal doesn't revoke seat or adjust billing

organizations-actions.ts:498-559 removes the member but never calls removeSeatAction or updateSubscriptionSeats. The org keeps paying for a ghost seat.

Flagged by: Edge Cases

9. Team Stripe customer tied to individual admin, not org

subscriptions-actions.ts:377-379 creates the Stripe customer under the admin's personal email/ID. If that admin leaves, the portal becomes inaccessible to the new admin since createPortalSession looks up by userId.

Flagged by: Edge Cases

10. No plan switching within the app

Active subscribers who try to upgrade (Starter → Professional) hit "already has active subscription" at subscriptions-actions.ts:79. The only path is through Stripe's Customer Portal, which depends on external dashboard configuration.

Flagged by: Workflow, Edge Cases

11. Post-checkout race condition — user sees no subscription

After Stripe checkout, the user redirects to the billing page before the subscription.created webhook is processed. They see "no subscription" and may re-attempt checkout.

Flagged by: Workflow

12. No payment failure notification

handleInvoicePaymentFailed() at route.ts:411-422 updates the DB but never notifies the user via email or in-app message.

Flagged by: Workflow

13. Missing database indexes on subscription tables

subscriptions-schema.ts has zero explicit indexes. subscriptions.userId is queried on every authenticated request via enrichSession. At scale, this degrades linearly.

Flagged by: Performance

14. Redundant auth() + getUserSubscription() cascade

Every server action calls auth() then getUserSubscription() which calls auth() again internally. The billing page triggers 4-6 redundant DB queries per load.

Flagged by: Performance, Code Quality

15. Missing webhook event types

No handling for: charge.dispute.created, charge.refunded, customer.subscription.paused, customer.subscription.trial_will_end. A chargeback gives the user 75+ days of free access.

Flagged by: Edge Cases

16. Session caches stale subscription data

The JWT session caches planType at login time. If a subscription changes (payment fails, webhook updates status), the session shows the old plan until re-authentication.

Flagged by: Workflow, Performance


Medium — Clean Up

17. Org membership check duplicated 4x in subscriptions-actions.ts

The identical organizationMembers query + ["owner", "admin", "billing_admin"].includes(role) appears at lines 353, 419, 480, and 571. Should be a shared helper.

Flagged by: Code Quality

18. any type leak in feature gating hot path

upgrade-modal-context.tsx:74let limits: any = LIMITS[planType] discards type safety on the code path that gates paid features.

Flagged by: Code Quality

19. Sequential Stripe API calls in webhooks

route.ts:171 and route.ts:186 make two sequential Stripe calls that could be Promise.all'd, saving 200-500ms per webhook.

Flagged by: Performance

20. Seat count queries fetch all rows instead of COUNT

subscriptions-actions.ts:438, 524, 604 use findMany + .length instead of a SQL COUNT(*).

Flagged by: Performance

21. Dead webhookHandlers export in stripe.ts

stripe.ts:291-326 exports a handlers object where every function just logs. Never imported anywhere.

Flagged by: Code Quality

22. Stripe/DB desync on seat update failure

subscriptions-actions.ts:450-459 updates Stripe first, then DB. If the DB write fails, Stripe bills for more seats than the DB tracks. No rollback mechanism.

Flagged by: Workflow

23. Lifetime purchases defined but not implemented

pricing.ts defines lifetime prices and Stripe IDs but the checkout flow only handles mode: "subscription". A lifetime payment would be charged but produce no subscription record.

Flagged by: Edge Cases


Minor

#FindingSource
24COMPARABLE_FEATURES in plan-card.tsx is a 3rd source of plan truthCode Quality
25Inconsistent error return shapes ({ url, error } vs { success, error })Code Quality
26metadata columns use text instead of jsonbCode Quality
27Full Stripe event objects stored in billing_events.metadata (5-20KB each)Performance
28E2E test bypass via NEXT_PUBLIC_E2E_TEST client-visible env varEdge Cases
29No downgrade data handling (over-limit resources remain accessible)Edge Cases
30Hardcoded USD, card-only payments, no i18n pathEdge Cases
31Default savings percent (17%) hardcoded instead of derived from pricing configCode Quality

  1. Yearly billing bug (#1) — revenue-impacting, small fix
  2. Feature gating ignores subscription status (#2) — security/revenue
  3. Add unique constraint + broader status check for duplicate subs (#3)
  4. Cancel Stripe on org deletion (#4)
  5. Consolidate STRIPE_CONFIG into pricing.ts (#5)
  6. Validate redirect URLs (#6)
  7. Add DB indexes (#13) — low effort, high scale impact
  8. Make credit sync idempotent (#7)
  9. Wire up seat revocation on member removal (#8)
  10. Everything else in priority order