[Fix]: Can now switch subscriptions/ create checkout urls even when stripeAccountId is null with test mode#1519
[Fix]: Can now switch subscriptions/ create checkout urls even when stripeAccountId is null with test mode#1519nams1570 wants to merge 6 commits into
Conversation
This can happen now even when payments onboarding is incomplete/ they are using remote emulator. Note that switching a stripe backed sub to a test sub or vice versa is still not possible rn. This is intentional, it will require some rethinking.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds test-mode awareness across payments: Stripe initialization is conditional, verification codes and validate responses allow nullable Stripe fields, purchase-session validates Stripe identifiers, the dashboard shows a dedicated test-mode bypass UI, subscription switching short-circuits DB grants in test mode, and E2E tests exercise test vs live flows. ChangesTest Mode Payment Flow
🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR allows payment checkout and subscription switching flows to work in test mode even when a project has no connected Stripe account, while preserving live-mode onboarding requirements.
Changes:
- Skips Stripe account/customer lookup when creating test-mode purchase URLs and returns nullable Stripe fields from validation.
- Adds a dashboard test-mode bypass path and live-mode “payments not enabled” state.
- Adds backend switch-plan handling/tests for DB-only test-mode grants and updates OpenAPI docs.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts |
Makes Stripe setup optional for test-mode purchase URL creation. |
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx |
Guards live purchase sessions against codes without Stripe identifiers. |
apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx |
Allows purchase verification codes to omit Stripe metadata. |
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts |
Returns nullable Stripe metadata for validated purchase codes. |
apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts |
Adds test-mode DB grant path for switching non-Stripe subscriptions. |
apps/dashboard/src/components/payments/checkout.tsx |
Extracts the test-mode bypass UI from the Stripe checkout form. |
apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx |
Branches checkout rendering for test mode, missing Stripe account, and Stripe checkout. |
apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts |
Adds live/test-mode coverage for purchase URL creation without onboarding. |
apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts |
Mirrors live-mode no-onboarding test updates for legacy naming. |
apps/e2e/tests/backend/endpoints/api/v1/payments/switch-plans.test.ts |
Adds switch-plan coverage for test-mode grants and live-mode rejection. |
docs-mintlify/openapi/server.json |
Updates validate-code response required fields. |
docs-mintlify/openapi/client.json |
Updates validate-code response required fields. |
docs-mintlify/openapi/admin.json |
Updates validate-code response required fields. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Greptile SummaryThis PR enables creating purchase checkout URLs and switching subscriptions in test mode even when no Stripe connected account (
Confidence Score: 5/5Safe to merge. The test-mode and live-mode paths are cleanly separated; the switch route's mutually-exclusive condition branches are exhaustive and verified by the new E2E tests. The core logic changes are well-structured, all live-mode behavior is unchanged, and the new E2E tests cover the four key scenarios. The one finding is a minor UI edge case affecting only products with no prices — an unusual configuration in practice. No files require special attention. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[create-purchase-url] --> B{testMode?}
B -- Yes --> C[Skip Stripe\nstripeCustomerId/AccountId = undefined]
B -- No --> D[Create Stripe customer\nFetch stripeAccountId & chargesEnabled]
C --> E[Store verification code\nStripe fields optional]
D --> E
E --> F[validate-code]
F --> G{testMode?}
G -- Yes --> H[Return stripe_account_id: null\ncharges_enabled: null]
G -- No --> I[Return stripe_account_id: string\ncharges_enabled: boolean]
H --> J[UI: TestModeBypassForm]
I --> K{stripe_account_id null?}
K -- Yes --> L[UI: Payments not enabled]
K -- No --> M[UI: Stripe CheckoutForm]
J --> N[handleBypass -> /internal/test-mode-purchase-session]
M --> O[purchase-session]
N --> P{testMode active?}
P -- No --> Q[403 Forbidden]
P -- Yes --> R[grantProductToCustomer]
O --> S{stripeAccountId/CustomerId null?}
S -- Yes --> T[HexclaveAssertionError]
S -- No --> U[Stripe payment flow]
subgraph Switch Route
V[switch/route] --> W{testMode?}
W -- Yes, no Stripe sub --> X[grantProductToCustomer DB-only grant]
W -- Yes, Stripe sub exists --> Y[400: Cannot switch live sub in test mode]
W -- No --> Z[Stripe subscription update/create]
end
Prompt To Fix All With AIFix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx:397-400
The `disabled` prop on `TestModeBypassForm` doesn't guard against a null `selectedPriceId`. If a product is configured with no prices and a purchase URL is created for it in test mode, the button will be enabled — but `handleBypass` will send `price_id: null`, which fails the `yupString().defined()` validation on the backend (producing an unhandled error). Adding `!selectedPriceId` to the disable condition keeps the UX consistent with the live checkout path, which also requires a price to be selected before proceeding.
```suggestion
<TestModeBypassForm
onBypass={handleBypass}
disabled={quantityNumber < 1 || isTooLarge || data.already_bought_non_stackable === true || !selectedPriceId}
/>
```
Reviews (2): Last reviewed commit: "chore: update tests" | Re-trigger Greptile |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (4)
apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts (1)
112-114: 💤 Low valueEscape
customer_idwhen building the Stripe customers search query.
metadata['customerId']:'${req.body.customer_id}'interpolatescustomer_iddirectly into Stripe’s Search Query Language string; a value containing'(and\) can break the query / enable query injection. Escape/sanitize the value before interpolation (e.g., replace\with\\and'with\', or centralize this in a small helper).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts` around lines 112 - 114, The Stripe customer search query interpolates req.body.customer_id directly into the Search Query Language string in the stripe.customers.search call (variable stripeCustomerSearch), which can break or allow injection if the value contains backslashes or single quotes; before building the query, sanitize/escape the customer_id (e.g., replace "\" with "\\\\" and "'" with "\\'") or centralize this logic in a small helper (escapeStripeSearchQuery or similar) and use that escaped value when constructing `query: \`metadata['customerId']:'${...}'\`` so the generated query string is safe.docs-mintlify/openapi/server.json (1)
6744-6749: OpenAPI “required” update matches generator logic (nullable → non-required)File:
docs-mintlify/openapi/server.json(around lines 6744-6749)
The/payments/purchases/validate-code(POST,responses.200.content.application/json.schema)requiredlist includes"test_mode"but excludes"stripe_account_id"and"charges_enabled".This aligns with backend schema + generator behavior:
- Backend:
apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
stripe_account_id:yupString().nullable().defined()charges_enabled:yupBoolean().nullable().defined()test_mode:yupBoolean().defined()- OpenAPI generator:
apps/backend/src/lib/openapi.tsxmarks a field asrequiredonly when it’s notoptionaland notnullable(required: !(field as any).optional && !(field as any).nullable && !!schema).Still, ensure
docs-mintlify/openapi/*.jsonwas regenerated via the OpenAPI docs generation workflow so the committed artifact matches generator output (no manual JSON edits).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs-mintlify/openapi/server.json` around lines 6744 - 6749, The OpenAPI response schema for POST /payments/purchases/validate-code has `test_mode` listed as required while `stripe_account_id` and `charges_enabled` are omitted because the generator treats nullable fields as non-required; regenerate the OpenAPI JSON artifacts (docs-mintlify/openapi/*.json) using the project's OpenAPI docs generation workflow so the committed JSON matches the generator output, and confirm the generator logic in openapi.tsx (the required: !(field as any).optional && !(field as any).nullable && !!schema check) aligns with the backend route schema in validate-code/route.ts (yupString().nullable().defined() and yupBoolean().nullable().defined() vs yupBoolean().defined()) before committing the regenerated files.apps/e2e/tests/backend/endpoints/api/v1/payments/switch-plans.test.ts (1)
320-329: 💤 Low valuePrefer inline snapshots for the success responses.
The status + body assertions on these switch responses can be collapsed into a single
toMatchInlineSnapshot, which also captures any unexpected extra fields. The URLtoMatchassertions elsewhere must stay as-is (random code), but these{ success: true }responses are good snapshot candidates.♻️ Example
- expect(switchResponse.status).toBe(200); - expect(switchResponse.body).toEqual({ success: true }); + expect(switchResponse).toMatchInlineSnapshot();As per coding guidelines: "Prefer
.toMatchInlineSnapshotover other selectors when writing tests".Also applies to: 387-396
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/e2e/tests/backend/endpoints/api/v1/payments/switch-plans.test.ts` around lines 320 - 329, Replace the separate status and body assertions for the payment plan switch response with a single inline snapshot assertion: capture the full response object returned by niceBackendFetch (the switchResponse variable) using expect(switchResponse).toMatchInlineSnapshot(...) so the test validates both status and body (and any extra fields) in one assertion; do the same for the other switch test that uses the same pattern (the other switchResponse usage around the later test) and prefer toMatchInlineSnapshot over the split expect(...status) and expect(...body) checks.apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts (1)
174-209: 💤 Low valueMake test-mode reliance explicit.
This test asserts test-mode behavior but never sets
payments.testMode, relying on the default beingtrue. If that default ever changes, this test would silently start exercising live mode. Consider setting it explicitly to harden intent.♻️ Suggested change
await Project.updateConfig({ payments: { + testMode: true, products: {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts` around lines 174 - 209, The test "should create purchase URL in test mode without Stripe onboarding" relies on the default payments.testMode being true; make the intent explicit by updating the Project.updateConfig call to include payments.testMode: true so the test runs in test mode regardless of defaults — modify the config object passed to Project.updateConfig (the same call that sets payments.products) to include a payments.testMode = true flag.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@apps/backend/src/app/api/latest/payments/products/`[customer_type]/[customer_id]/switch/route.ts:
- Around line 197-210: The test-mode short-circuit can leave the old
server-granted subscription (existingSub without stripeSubscriptionId) active
because the branch at route.ts does not cancel or update it before calling
grantProductToCustomer; inspect the grantProductToCustomer implementation and if
it does not already replace/cancel same-product-line subscriptions, modify this
branch to explicitly cancel or deactivate the prior existingSub (e.g., update
its status via Prisma or call the same internal cancel logic used for
Stripe-path updates) before calling grantProductToCustomer so the customer ends
up with a single active product; reference symbols: existingSub,
stripeSubscriptionId, grantProductToCustomer, and the test-mode branch in
route.ts.
In
`@apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx`:
- Around line 67-79: Replace the boolean coercions on Stripe IDs with explicit
nullish checks: change the guard in the route handler from `if
(!data.stripeAccountId || !data.stripeCustomerId)` to `if (data.stripeAccountId
== null || data.stripeCustomerId == null)` and update the diagnostic properties
`hasStripeAccountId` and `hasStripeCustomerId` to use `data.stripeAccountId !=
null` and `data.stripeCustomerId != null` respectively; keep the
HexclaveAssertionError construction (and other fields like tenancy.id,
data.customerId, tenancy.config.payments.testMode) unchanged.
In `@apps/dashboard/src/components/payments/checkout.tsx`:
- Around line 45-51: The TestModeBypassForm currently passes the
Promise-returning handler directly to the Button as onClick={onBypass}, so
rejections from handleBypass aren’t surfaced; import and use
runAsynchronouslyWithAlert to wrap the async handler and pass the wrapped
function to the Button (i.e., replace onClick={onBypass} with
onClick={runAsynchronouslyWithAlert(onBypass)}), ensuring the import for
runAsynchronouslyWithAlert is added and the symbols TestModeBypassForm, onBypass
and handleBypass are the targets for this change.
In `@docs-mintlify/openapi/admin.json`:
- Around line 6836-6841: The change touches a generated OpenAPI JSON (the
"required" array update) and must not be edited directly; update the source
schema generator (the Yup schema or OpenAPI metadata that defines the fields
like project_id, already_bought_non_stackable, conflicting_products, test_mode)
to mark the correct nullable/required settings, then run the “Regenerate OpenAPI
schemas” workflow to regenerate docs-mintlify/openapi/*.json so the generated
admin.json reflects the change; ensure the generator unit/metadata changes are
committed and the CI regeneration completes before pushing.
---
Nitpick comments:
In
`@apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts`:
- Around line 112-114: The Stripe customer search query interpolates
req.body.customer_id directly into the Search Query Language string in the
stripe.customers.search call (variable stripeCustomerSearch), which can break or
allow injection if the value contains backslashes or single quotes; before
building the query, sanitize/escape the customer_id (e.g., replace "\" with
"\\\\" and "'" with "\\'") or centralize this logic in a small helper
(escapeStripeSearchQuery or similar) and use that escaped value when
constructing `query: \`metadata['customerId']:'${...}'\`` so the generated query
string is safe.
In
`@apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts`:
- Around line 174-209: The test "should create purchase URL in test mode without
Stripe onboarding" relies on the default payments.testMode being true; make the
intent explicit by updating the Project.updateConfig call to include
payments.testMode: true so the test runs in test mode regardless of defaults —
modify the config object passed to Project.updateConfig (the same call that sets
payments.products) to include a payments.testMode = true flag.
In `@apps/e2e/tests/backend/endpoints/api/v1/payments/switch-plans.test.ts`:
- Around line 320-329: Replace the separate status and body assertions for the
payment plan switch response with a single inline snapshot assertion: capture
the full response object returned by niceBackendFetch (the switchResponse
variable) using expect(switchResponse).toMatchInlineSnapshot(...) so the test
validates both status and body (and any extra fields) in one assertion; do the
same for the other switch test that uses the same pattern (the other
switchResponse usage around the later test) and prefer toMatchInlineSnapshot
over the split expect(...status) and expect(...body) checks.
In `@docs-mintlify/openapi/server.json`:
- Around line 6744-6749: The OpenAPI response schema for POST
/payments/purchases/validate-code has `test_mode` listed as required while
`stripe_account_id` and `charges_enabled` are omitted because the generator
treats nullable fields as non-required; regenerate the OpenAPI JSON artifacts
(docs-mintlify/openapi/*.json) using the project's OpenAPI docs generation
workflow so the committed JSON matches the generator output, and confirm the
generator logic in openapi.tsx (the required: !(field as any).optional &&
!(field as any).nullable && !!schema check) aligns with the backend route schema
in validate-code/route.ts (yupString().nullable().defined() and
yupBoolean().nullable().defined() vs yupBoolean().defined()) before committing
the regenerated files.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 006d2147-209e-46b4-a330-64a51d13a431
📒 Files selected for processing (13)
apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.tsapps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.tsapps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsxapps/backend/src/app/api/latest/payments/purchases/validate-code/route.tsapps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsxapps/dashboard/src/app/(main)/purchase/[code]/page-client.tsxapps/dashboard/src/components/payments/checkout.tsxapps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.tsapps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.tsapps/e2e/tests/backend/endpoints/api/v1/payments/switch-plans.test.tsdocs-mintlify/openapi/admin.jsondocs-mintlify/openapi/client.jsondocs-mintlify/openapi/server.json
There was a problem hiding this comment.
1 issue found across 13 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
There was a problem hiding this comment.
🧹 Nitpick comments (1)
apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts (1)
212-219: 💤 Low valueGuard logic is correct; prefer explicit null checks per guidelines.
The new block is reachable and exhaustive: line 197 already returns for the
!existingSub || !existingSub.stripeSubscriptionIdtest-mode case, so this complementary guard correctly blocks the live-Stripe-sub-in-test-mode path before falling into the Stripe flow at Line 221.StatusError(400)is appropriate here (user-actionable, no internal info leaked).One nit on Line 213: the condition uses boolean truthiness rather than explicit null checks.
♻️ Use explicit null/undefinedness checks
- if (testMode && existingSub && existingSub.stripeSubscriptionId) { + if (testMode && existingSub != null && existingSub.stripeSubscriptionId != null) {As per coding guidelines: "Prefer explicit null/undefinedness checks over boolean checks (e.g.,
foo == nullinstead of!foo)".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/backend/src/app/api/latest/payments/products/`[customer_type]/[customer_id]/switch/route.ts around lines 212 - 219, Change the boolean truthiness checks in the guard to explicit null/undefined checks: replace the condition that uses "existingSub" and "existingSub.stripeSubscriptionId" with explicit null checks (e.g., existingSub != null and existingSub.stripeSubscriptionId != null) so the block reads testMode && existingSub != null && existingSub.stripeSubscriptionId != null; keep the thrown StatusError(400) and message unchanged to preserve behavior in route.ts where existingSub, stripeSubscriptionId, testMode and StatusError are used.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In
`@apps/backend/src/app/api/latest/payments/products/`[customer_type]/[customer_id]/switch/route.ts:
- Around line 212-219: Change the boolean truthiness checks in the guard to
explicit null/undefined checks: replace the condition that uses "existingSub"
and "existingSub.stripeSubscriptionId" with explicit null checks (e.g.,
existingSub != null and existingSub.stripeSubscriptionId != null) so the block
reads testMode && existingSub != null && existingSub.stripeSubscriptionId !=
null; keep the thrown StatusError(400) and message unchanged to preserve
behavior in route.ts where existingSub, stripeSubscriptionId, testMode and
StatusError are used.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: d0250772-575e-41db-87bd-e2fd1acacefd
📒 Files selected for processing (2)
apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.tsapps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
These routes are now accessible in test mode so tests need to be updated to be more explicit.
|
@greptileai please rereview |
| if (data.stripeAccountId == null || data.stripeCustomerId == null) { | ||
| throw new HexclaveAssertionError( |
There was a problem hiding this comment.
should have an e2e test for this
| if (testMode && (existingSub == null || existingSub.stripeSubscriptionId == null)) { | ||
| await grantProductToCustomer({ |
There was a problem hiding this comment.
In test mode, cancelling the old sub relies on the TimeFold-lagged ownedProducts read instead of the existingSub we already have, so two quick switches can leave two active subs in one line — cancel existingSub directly here.
Context
Remote/Local emulator allow one to set up payments configs without being provisioned stripe connected accounts. Previously, even if the user had test mode toggled, they couldn't create checkout urls or switch subscriptions. Now, they can thanks to the refactor.
Out of scope
The switch route still needs to be reworked for when we want to consider stripe-test mode or test mode - stripe switches. For now we should be good
Summary by cubic
Enable creating checkout URLs and switching subscriptions in test mode without a connected Stripe account (
stripeAccountIdcan be null). Block switching a Stripe-backed subscription while the project is in test mode; cancel it or disable test mode first. Live mode still requires Stripe.New Features
stripe_customer_id,stripe_account_id, andcharges_enabled.stripe_account_idis null.Bug Fixes
stripe_account_idandcharges_enabledare nullable and no longer required in validate-code responses.Written for commit fe8433d. Summary will update on new commits.
Summary by CodeRabbit
New Features
UI
Documentation
Tests