Commit 9ec0449
feat(pentest): subscription billing + GitHub repo selector (#2212)
* feat(pentest): subscription-based billing model
Replaces the mock checkout redirect with an inline subscription billing
model. Organizations subscribe to a monthly plan (3 runs included) and
overage runs are charged immediately via Stripe PaymentIntent at run
creation time — no redirect required.
- Add PentestSubscription DB model (organization relation, period tracking)
- Add billing server actions: subscribeToPentestPlan, handleSubscriptionSuccess,
checkAndChargePentestBilling (blocks run creation on billing failure)
- Add /[orgId]/security/penetration-tests/subscription management page
- Add /api/webhooks/stripe-pentest webhook handler (subscription updated/deleted)
- Remove mockCheckout from API DTO and client types
- Update useCreatePenetrationTest to call billing check before API post
- Update page client: remove checkout redirect/search-param handling,
navigate directly to report detail on success
- Add STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID, STRIPE_PENTEST_OVERAGE_PRICE_ID,
STRIPE_PENTEST_WEBHOOK_SECRET env vars
- Delete mock checkout page
- Update all tests to reflect new flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(billing): use SubscriptionItem for period dates in Stripe SDK v20+
In stripe@20.x with API version 2025-12-15.clover, current_period_start
and current_period_end were moved from the root Subscription type to
SubscriptionItem. Read them via subscription.items.data[0] instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(security): add Pentest Billing tab and Stripe billing portal
- Add "Pentest Billing" as a second tab in SecuritySidebar, pointing to
the existing subscription management page
- Fix active-tab detection so Penetration Tests doesn't stay highlighted
when on the Billing sub-page
- Add createBillingPortalSession server action so active subscribers can
manage their payment method via Stripe's hosted portal
- Fix return URL to use NEXT_PUBLIC_BETTER_AUTH_URL (was using
NEXT_PUBLIC_APP_URL which isn't defined)
- Fix subscribe button copy to reflect actual price ($99/month)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(pentest): address PR review findings
P1 — billing auth: validate session and org membership in
checkAndChargePentestBilling before any DB/Stripe calls, so a caller
cannot trigger billing against an org they don't belong to.
P1 — billing order: move checkAndChargePentestBilling to after successful
run creation so a transient provider failure never charges the customer
without delivering a run. Threshold adjusted from `<` to `<=` so the
included-run count is still respected once the new run is counted in DB.
P2 — GitHub token key: integration-platform GitHub connections use OAuth2
and store the token under `access_token`; fix getGithubTokenForOrg to
read that field instead of the legacy PAT key `GITHUB_TOKEN`.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(billing): lift stripeCustomerId to OrganizationBilling + Settings > Billing hub
- Add OrganizationBilling model (one per org, owns stripeCustomerId) so
future subscription products share a single Stripe customer
- PentestSubscription now relates to OrganizationBilling via FK instead
of owning stripeCustomerId directly
- New Settings > Billing page as the single hub for all app subscriptions
- Remove Pentest Billing tab from Security sidebar
- Delete old /security/penetration-tests/subscription page
- Update "Manage subscription" link to /settings/billing
- Add requireOrgMember() guard to all four billing server actions
(subscribeToPentestPlan, handleSubscriptionSuccess, createBillingPortalSession
were previously missing the cross-tenant auth check)
- Validate Stripe session ownership in handleSubscriptionSuccess:
reject if session customer doesn't match existing org billing record
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(webhook): handle checkout.session.completed to activate subscription server-side
Previously, PentestSubscription was only created when the user returned to
the /settings/billing success URL. If they closed the tab or the browser
crashed after Stripe Checkout, the subscription would never activate.
The webhook is now the primary activation path:
- checkout.session.completed → look up OrganizationBilling by stripeCustomerId,
retrieve full subscription, upsert PentestSubscription
- handleSubscriptionSuccess on the return URL becomes an idempotent fallback
(safe to call again since both paths use upsert)
Note: checkout.session.completed must be added to the Stripe webhook's
event subscriptions in the dashboard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(billing): harden Stripe customer binding and URL construction
- Remove findStripeCustomerByDomain fallback in subscribeToPentestPlan:
organization.website is tenant-controlled so domain-based customer
reuse could let a malicious org bind to another company's Stripe
customer. Always create a fresh customer when no billing row exists.
- Strengthen handleSubscriptionSuccess session ownership check:
previously only rejected mismatched customers when an OrganizationBilling
row already existed. Now if no row exists (edge case — subscribeToPentestPlan
always creates one first), verify the Stripe customer's metadata.organizationId
matches before accepting the session.
- Fix Stripe return URLs to always be absolute: derive origin from
NEXT_PUBLIC_BETTER_AUTH_URL with a request-header fallback so Stripe
never receives a relative URL when the env var is unset.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(billing): deduplicate concurrent overage charges + preserve manual repo URL input
- Add Stripe idempotency key to paymentIntents.create scoped to
orgId + period start + run number; concurrent creates at the quota
boundary now deduplicate to a single charge instead of double-billing
- Show manual URL input alongside the GitHub repo selector so users
with more than 100 repos (beyond the first-page fetch) can still
paste a repo URL directly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(billing): use per-run ID for overage charge idempotency key
The previous key used `runsThisPeriod` (an aggregate count), meaning
two concurrent creates could observe the same count and share the same
key — Stripe would deduplicate to a single PaymentIntent even when two
separate overage runs were billed.
Replace with `pentest-overage-{orgId}-{runId}` so each run gets a
globally unique idempotency key, eliminating the underbilling race.
Also include Codex-added tests for the GitHub connection UI flow.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>1 parent adc8644 commit 9ec0449
25 files changed
Lines changed: 1172 additions & 510 deletions
File tree
- apps
- api/src/security-penetration-tests
- dto
- app
- src
- app
- (app)/[orgId]
- components
- security
- components
- penetration-tests
- actions
- checkout
- hooks
- settings
- billing
- components
- api/webhooks/stripe-pentest
- lib/security
- packages/db/prisma
- migrations/20260303164429_add_organization_billing
- schema
Lines changed: 0 additions & 10 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
54 | | - | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
59 | | - | |
60 | | - | |
61 | | - | |
62 | | - | |
63 | | - | |
64 | 54 | | |
65 | 55 | | |
66 | 56 | | |
| |||
Lines changed: 15 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
76 | 76 | | |
77 | 77 | | |
78 | 78 | | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
79 | 94 | | |
80 | 95 | | |
81 | 96 | | |
| |||
Lines changed: 2 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
| 3 | + | |
3 | 4 | | |
4 | 5 | | |
5 | 6 | | |
6 | 7 | | |
7 | | - | |
| 8 | + | |
8 | 9 | | |
9 | 10 | | |
10 | 11 | | |
| |||
Lines changed: 24 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
4 | 5 | | |
5 | 6 | | |
6 | 7 | | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
7 | 12 | | |
8 | 13 | | |
9 | 14 | | |
| |||
16 | 21 | | |
17 | 22 | | |
18 | 23 | | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
19 | 30 | | |
20 | 31 | | |
21 | 32 | | |
| |||
30 | 41 | | |
31 | 42 | | |
32 | 43 | | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
33 | 50 | | |
34 | 51 | | |
35 | 52 | | |
| |||
58 | 75 | | |
59 | 76 | | |
60 | 77 | | |
61 | | - | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
62 | 81 | | |
63 | 82 | | |
64 | 83 | | |
| |||
77 | 96 | | |
78 | 97 | | |
79 | 98 | | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
80 | 103 | | |
81 | 104 | | |
82 | 105 | | |
| |||
Lines changed: 114 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| 12 | + | |
12 | 13 | | |
13 | 14 | | |
14 | 15 | | |
| |||
17 | 18 | | |
18 | 19 | | |
19 | 20 | | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
20 | 29 | | |
21 | 30 | | |
22 | 31 | | |
| |||
84 | 93 | | |
85 | 94 | | |
86 | 95 | | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
87 | 99 | | |
88 | 100 | | |
89 | 101 | | |
| |||
118 | 130 | | |
119 | 131 | | |
120 | 132 | | |
121 | | - | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
122 | 143 | | |
123 | 144 | | |
124 | 145 | | |
| |||
129 | 150 | | |
130 | 151 | | |
131 | 152 | | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
132 | 161 | | |
133 | 162 | | |
134 | 163 | | |
| |||
193 | 222 | | |
194 | 223 | | |
195 | 224 | | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
196 | 270 | | |
197 | 271 | | |
198 | 272 | | |
| |||
320 | 394 | | |
321 | 395 | | |
322 | 396 | | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
323 | 436 | | |
324 | 437 | | |
325 | 438 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
66 | | - | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
282 | 282 | | |
283 | 283 | | |
284 | 284 | | |
285 | | - | |
| 285 | + | |
286 | 286 | | |
287 | 287 | | |
288 | 288 | | |
| |||
Lines changed: 2 additions & 10 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
12 | | - | |
13 | | - | |
14 | | - | |
15 | | - | |
16 | | - | |
17 | 11 | | |
18 | 12 | | |
19 | 13 | | |
20 | | - | |
| 14 | + | |
21 | 15 | | |
22 | 16 | | |
23 | 17 | | |
24 | 18 | | |
25 | 19 | | |
26 | 20 | | |
27 | 21 | | |
28 | | - | |
29 | | - | |
30 | | - | |
| 22 | + | |
31 | 23 | | |
32 | 24 | | |
33 | 25 | | |
| |||
0 commit comments