Skip to content

feat: KEEP-1512 Stripe billing implementation#446

Open
joelorzet wants to merge 59 commits intostagingfrom
KEEP-1512-Stripe-implementation
Open

feat: KEEP-1512 Stripe billing implementation#446
joelorzet wants to merge 59 commits intostagingfrom
KEEP-1512-Stripe-implementation

Conversation

@joelorzet
Copy link

@joelorzet joelorzet commented Feb 26, 2026

Summary

Add Stripe-based billing system with plan management, proration previews, invoice history, and deployment configuration for staging and production environments.

Details

  • Billing database schema, migration, and Stripe provider integration
  • Billing API routes: checkout, webhook, subscription management, invoices, proration preview
  • Billing UI: plan selection, plan change confirmation with proration breakdown, invoice history, cancellation flow
  • Organization-aware billing and analytics data reloading on org switch
  • Stripe webhook handling for checkout, subscription updates/cancellations, and invoice events
  • NEXT_PUBLIC_BILLING_ENABLED feature flag: all billing API routes, UI pages, and navigation are guarded behind this build-time flag (enabled in staging, disabled in prod)
  • Deploy configuration for staging and prod with the following environment variables (AWS Parameter Store):
    • NEXT_PUBLIC_BILLING_ENABLED (kv: "true" staging, "false" prod)
    • STRIPE_SECRET_KEY
    • STRIPE_WEBHOOK_SECRET
    • STRIPE_PRICE_PRO_25K_MONTHLY
    • STRIPE_PRICE_PRO_25K_YEARLY
    • STRIPE_PRICE_PRO_50K_MONTHLY
    • STRIPE_PRICE_PRO_50K_YEARLY
    • STRIPE_PRICE_PRO_100K_MONTHLY
    • STRIPE_PRICE_PRO_100K_YEARLY
    • STRIPE_PRICE_BUSINESS_250K_MONTHLY
    • STRIPE_PRICE_BUSINESS_250K_YEARLY
    • STRIPE_PRICE_BUSINESS_500K_MONTHLY
    • STRIPE_PRICE_BUSINESS_500K_YEARLY
    • STRIPE_PRICE_BUSINESS_1M_MONTHLY
    • STRIPE_PRICE_BUSINESS_1M_YEARLY
    • STRIPE_PRICE_ENTERPRISE_MONTHLY
    • STRIPE_PRICE_ENTERPRISE_YEARLY

Test coverage

  • 84 unit tests: feature flag, plan definitions, webhook event handler, Stripe provider, server-side helpers
  • 22 integration tests: checkout, subscription, cancel, and webhook API routes
  • 6 E2E tests: billing page rendering, pricing, checkout flow, subscription status, cancel flow, invoice history

…tion

Stripe portal cancellations set cancel_at instead of cancel_at_period_end.
Derive cancelAtPeriodEnd from both flags so the UI correctly reflects
cancellation and un-cancellation state.

Also use current_period_end instead of cancel_at for the billing period
end date, which is always present regardless of cancellation state.
Show loading skeleton while fetching proration data, display
"No charge" instead of negative amounts when the user has a
credit from unused time, and use "Proration breakdown" as the
section header for credit scenarios.
Use Stripe's amount_due instead of manually summing line items,
which correctly accounts for customer credit balance. Show
applied credit in the proration breakdown and display "No charge"
when nothing is owed.
Add all 16 Stripe environment variables (secret key, webhook secret,
and 14 price IDs) to both staging and prod values.yaml using
AWS Parameter Store references.
@joelorzet joelorzet marked this pull request as draft February 26, 2026 20:37
Guard all billing API routes, UI pages, and navigation behind
a build-time feature flag so billing is disabled by default.

- Add feature-flag.ts module with isBillingEnabled() check
- Make stripe.ts return null instead of throwing when key is missing
- Add getStripe() helper in stripe provider for null-safe access
- Add isBillingEnabled() guard to getBillingProvider()
- Add early 404 return to all 7 billing API routes when disabled
- Hide billing page (notFound) and nav item when disabled
- Add NEXT_PUBLIC_BILLING_ENABLED to Dockerfile and .env.example
- Remove console.log statements from webhook route
Add NEXT_PUBLIC_BILLING_ENABLED and Stripe price ID env vars
to tests/setup.ts so billing tests have required configuration.
Unit tests (84 tests):
- feature-flag: env var parsing
- plans: getPriceId, resolvePriceId, getPlanLimits, PLANS structure
- handle-billing-event: all 7 webhook event handlers
- stripe-provider: all provider methods and UnknownEventTypeError
- plans-server: getOrgSubscription, getOrgPlan, checkFeatureAccess,
  checkExecutionLimit

Integration tests (22 tests):
- checkout route: auth, validation, checkout/update flows
- subscription route: plan data with limits, free fallback
- cancel route: auth, ownership, cancellation flow
- webhook route: signature validation, dedup, event processing

E2E tests (6 tests):
- Billing page rendering, pricing, checkout flow, subscription
  status, cancel flow, invoice history (all with mocked API routes)
Enable billing in staging, disable in prod.
- Refuse unknown price IDs instead of defaulting to "pro" (handle-billing-event)
- Fix isCurrentPlan wildcard matching on null tier/interval (pricing-table/utils)
- Use unique React keys for proration line items (confirm-plan-change-dialog)
- Add owner role check to invoices and preview-proration routes
- Return generic error message from webhook handler (no internal details)
- Use event periodEnd before DB fallback in subscription.deleted
- Clear checkout query param after showing toast (billing-page)
- Fix dead ternary in price display (confirm-plan-change-dialog)
- Add error logging to silent fetch failures (billing-page, billing-status, billing-history)
- Add warning log when checkout update affects zero rows
- Extract requireOrgOwner helper to reduce complexity in preview-proration route
- Add integration tests for invoices and preview-proration routes
- Update webhook and handle-event tests for changed behavior
- Make billing page a Server Component so notFound() works correctly
- Add database index on provider_subscription_id for webhook query performance
- Fix webhook idempotency TOCTOU race with atomic INSERT ON CONFLICT DO NOTHING
- Add server-side same-plan guard to reject redundant checkout requests
- Fix isCurrentPlan returning false for free plan users viewing free card
- Extract shared requireOrgOwner() helper to deduplicate auth logic and
  eliminate redundant headers() calls across all 5 billing routes
- Fix upgrade-prompt text color invisible in light mode
…e customers

- Move PRICE_IDS, getPriceId, resolvePriceId from plans.ts to
  plans-server.ts to prevent accidental client-side usage of
  server-only env vars
- Replace TOCTOU check-then-insert in ensureProviderCustomer with
  atomic onConflictDoUpdate using COALESCE to prevent duplicate
  Stripe customers on concurrent checkout requests
- Add onConflictDoNothing to afterCreateOrganization hook to handle
  race between org creation and checkout insert
@joelorzet joelorzet marked this pull request as ready for review March 2, 2026 13:36
…ions

Add enforcement of monthly execution limits at all workflow entry points
(API, webhook, internal). Paid plans that exceed limits are allowed but
tracked for overage billing via Stripe invoice items. Free plans are
hard-blocked. Includes upgrade suggestion API and UI banner, overage
billing records table, and comprehensive tests.
… allowance

When a paid org exceeds its monthly execution limit, overage is billed
via Stripe invoice items. If the invoice remains unpaid after a 15-day
grace period, the unpaid overage executions are deducted from the next
month's allowance (minimum floor of 100 executions). Debt clears
automatically when the invoice is paid.

- Add execution_debt table and migration
- Add debt scan endpoint for daily scheduler runs
- Extend BillingProvider with invoice status methods
- Handle Stripe 404 for consumed invoice items
- Update checkExecutionLimit to apply debt deduction
- Clear debt on invoice.paid and subscription.deleted events
- Guard against concurrent scans with onConflictDoNothing
joelorzet and others added 27 commits March 3, 2026 14:58
The guard response body was re-wrapped into a new NextResponse losing
Content-Type: application/json. Use NextResponse.json instead.
Add doc comments explaining the check-then-act race condition is
intentional and bounded. Add tests for resolvePriceId round-trip
and unknown price ID handling.
Production code should not use console.log per project lint rules.
All informational billing event logs now use console.warn.
Also adds comment explaining why invoice.paid fallback omits status.
Track org+period keys processed in the subscription scan loop and
skip them in the failed-records retry loop to prevent misleading
double entries in the response.
The projection used local-time getDate/getMonth while the usage
query used UTC, causing mismatches near month boundaries. Also
suppresses suggestions when fewer than 3 days of data exist to
avoid extreme projections on day 1-2.
The error string was duplicated between execution-guard.ts and the
webhook route. Extract to a shared constant so changes stay in sync.
Add comment explaining why debtExecutions is hardcoded to 0 for
unlimited plans. Use it.skipIf for resolvePriceId tests that
depend on Stripe env vars so skips are visible in test output.
Track actual retry attempts instead of reporting fetched record count.
Include canceled subscriptions in the scan query so their final period
overage is not missed.
Replace loose optional fields with SuggestionNoUpgrade | SuggestionUpgrade
discriminated union so all fields are guaranteed present when shouldUpgrade
is true, eliminating potential undefined rendering in JSX.
- Show execution usage progress bar on billing status page
- Show proper "Monthly execution limit exceeded" toast on 429
- Add overage charges section with pending/billed status
- Add "View Plans" CTA on upgrade suggestion banner
- Softer amber tones for paid plan overage (not destructive red)
- Fix tier suggestion to prefer same-plan upgrades over cross-plan jumps
- Return usage and overage data from subscription API endpoint
- Charge per exact execution instead of rounding up to nearest 1,000
- Add overage bar extension showing excess usage beyond plan limit
- Show unpaid overage records using providerInvoiceId instead of status
- Bold per-execution rate in overage message
- Consistent bar colors: green (safe), yellow (near limit), muted+red (over)
…id on invoice

- Block paid plan executions when active debt exists (unpaid overage past 15-day grace)
- Add debt-specific error message for suspended executions
- Mark overage billing records as paid when invoice.paid webhook fires
- Move debt check before usage comparison to prevent early allow bypass
- Fix overage test: 1,500 executions at $2/1000 = $3.00 (per-execution math)
- Update debt tests: paid plans with active debt are now fully blocked
Add db.execute mock for usage count query and overageBillingRecords
mock for overage charges in subscription route test. Add db.select,
execution-debt, and overageBillingRecords schema mocks to webhook
route test for invoice.paid handler changes.
…uards

feat: KEEP-1515 add billing execution guards, overage billing, and execution debt
- execute/webhook routes: keep both billing guard and concurrency limit
- navigation-sidebar: keep billing, org hooks, and anonymous user imports
- drizzle migrations: renumber billing migrations 0026-0029 to 0027-0030
  to accommodate staging's 0026_puzzling_thunderball
- update snapshot prevId chain and add missing execution status index
Migration 0028_add_provider_sub_id_index already creates
idx_org_subscriptions_provider_sub with IF NOT EXISTS. The duplicate
CREATE INDEX in 0029_opposite_human_cannonball (without IF NOT EXISTS)
fails on fresh databases.
Lexicographic string comparison incorrectly ranks "99.99%" below "99.9%".
Switch to parseFloat for correct numeric comparison.
The checkout and cancel tests wrapped assertions in catch(() => false)
guards, causing tests to pass green even if buttons never rendered.
Replace with proper toBeVisible assertions that fail on missing elements.
Replace unnecessary dynamic import of drizzle-orm eq with a static
import, consistent with all other files in the codebase.
console.warn was used for expected operational events (checkout completed,
invoice paid, subscription updated, etc.), muddying log severity. Reserve
console.warn for actual warnings (missing rows, billing failures).
Replace unsafe type assertions (as PlanName, as TierKey) with runtime
validation via parsePlanName/parseTierKey helpers. Falls back to safe
defaults (free plan, null tier) on invalid data instead of silently
producing incorrect behavior.
pendingTotal was calculated from the full charges array but the
displayed list filters to charges without a providerInvoiceId. This
could show a total exceeding what the user sees on screen.
Hardcoded "5,000" string would drift if the free tier limit changes.
Use PLANS.free.features.maxExecutionsPerMonth.toLocaleString() instead.
The useBillingData hook silently swallowed fetch errors, leaving a blank
card with no user feedback. Add error state and render a fallback
message when the subscription API fails.
Replace type assertions with parsePlanName/parseTierKey helpers for
consistent validation of server response data.
The field represents the per-month price when billed annually, not the
annual total. The old name was misleading and could cause confusion
when reading annualTotal = yearlyPrice * 12.
@suisuss
Copy link

suisuss commented Mar 4, 2026

@joelorzet plan is to merge #472 then verify all the testing for this PR, do a PR Deploy, manual test and then merge

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants