mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-03 21:02:05 +08:00
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- RECURSEML_SUMMARY:START --> ## High-level PR Summary This PR fixes payment test mode behavior by changing the default test mode setting to `true` and refactoring the test mode bypass UI. The bypass functionality is moved from a floating card in the purchase page into the checkout form itself, providing a cleaner and more integrated experience. Additionally, the database migration configuration is updated to increase the `maxWait` timeout to handle concurrent migration attempts more gracefully in high-contention scenarios like CI environments. ⏱️ Estimated Review Time: 5-15 minutes <details> <summary>💡 Review Order Suggestion</summary> | Order | File Path | |-------|-----------| | 1 | `packages/stack-shared/src/config/schema.ts` | | 2 | `apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx` | | 3 | `apps/dashboard/src/components/payments/checkout.tsx` | | 4 | `apps/backend/src/auto-migrations/index.tsx` | </details> <details> <summary>⚠️ Inconsistent Changes Detected</summary> | File Path | Warning | |-----------|---------| | `apps/backend/src/auto-migrations/index.tsx` | Database migration timeout configuration changes appear unrelated to payment test mode fixes, which is the stated purpose of this PR | </details> [](https://discord.gg/n3SsVDAW6U) [ <!-- RECURSEML_SUMMARY:END --> <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Sets payment test mode to default true, integrates test mode bypass into checkout form, and updates migration timeout. > > - **Behavior**: > - Default `testMode` set to `true` in `schema.ts`. > - Integrates test mode bypass into `CheckoutForm` in `checkout.tsx`. > - Removes separate bypass panel from `page-client.tsx`. > - **Database**: > - Increases `maxWait` timeout in `index.tsx` to handle concurrent migration attempts. > - **Tests**: > - Updates tests in `backend-helpers.ts` and `validate-code.test.ts` to reflect test mode behavior. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup> for6313c0bfed. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> ---- <!-- ELLIPSIS_HIDDEN --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a test-mode checkout path with a “Complete test purchase” action when test mode is active. * **Refactor** * Consolidated test-mode bypass into the checkout component and removed the separate bypass UI. * **Bug Fixes** * Improved reliability of database migrations by extending the transaction wait window, reducing timeout errors under load. * **Chores** * Payments now default to test mode enabled. * **Tests** * Updated tests and payload expectations to reflect test mode defaults and behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
import { it } from "../../../../../helpers";
|
|
import { Payments, Project, User, niceBackendFetch } from "../../../../backend-helpers";
|
|
|
|
|
|
it("should error on invalid code", async ({ expect }) => {
|
|
await Project.createAndSwitch();
|
|
const response = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: {
|
|
full_code: "invalid-code",
|
|
},
|
|
});
|
|
expect(response).toMatchInlineSnapshot(`
|
|
NiceResponse {
|
|
"status": 404,
|
|
"body": {
|
|
"code": "VERIFICATION_CODE_NOT_FOUND",
|
|
"error": "The verification code does not exist for this project.",
|
|
},
|
|
"headers": Headers {
|
|
"x-stack-known-error": "VERIFICATION_CODE_NOT_FOUND",
|
|
<some fields may have been hidden>,
|
|
},
|
|
}
|
|
`);
|
|
});
|
|
|
|
it("should allow valid code and return product data", async ({ expect }) => {
|
|
const { code } = await Payments.createPurchaseUrlAndGetCode();
|
|
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: { full_code: code },
|
|
});
|
|
expect(validateResponse).toMatchInlineSnapshot(`
|
|
NiceResponse {
|
|
"status": 200,
|
|
"body": {
|
|
"already_bought_non_stackable": false,
|
|
"conflicting_products": [],
|
|
"product": {
|
|
"customer_type": "user",
|
|
"display_name": "Test Product",
|
|
"included_items": {},
|
|
"prices": {
|
|
"monthly": {
|
|
"USD": "1000",
|
|
"interval": [
|
|
1,
|
|
"month",
|
|
],
|
|
},
|
|
},
|
|
"server_only": false,
|
|
"stackable": false,
|
|
},
|
|
"project_id": "<stripped UUID>",
|
|
"stripe_account_id": <stripped field 'stripe_account_id'>,
|
|
"test_mode": false,
|
|
},
|
|
"headers": Headers { <some fields may have been hidden> },
|
|
}
|
|
`);
|
|
});
|
|
|
|
it("should set already_bought_non_stackable when user already owns non-stackable product", async ({ expect }) => {
|
|
await Project.createAndSwitch();
|
|
await Payments.setup();
|
|
await Project.updateConfig({
|
|
payments: {
|
|
testMode: true,
|
|
products: {
|
|
"test-product": {
|
|
displayName: "Test Product",
|
|
customerType: "user",
|
|
serverOnly: false,
|
|
stackable: false,
|
|
prices: {
|
|
monthly: {
|
|
USD: "1000",
|
|
interval: [1, "month"],
|
|
},
|
|
},
|
|
includedItems: {},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const { userId } = await User.create();
|
|
// Create a code for test-product and purchase it in test mode (creates DB subscription)
|
|
const createUrlRes1 = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: {
|
|
customer_type: "user",
|
|
customer_id: userId,
|
|
product_id: "test-product",
|
|
},
|
|
});
|
|
expect(createUrlRes1.status).toBe(200);
|
|
const code1 = (createUrlRes1.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
|
|
expect(code1).toBeDefined();
|
|
|
|
const testModeRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
|
|
method: "POST",
|
|
accessType: "admin",
|
|
body: {
|
|
full_code: code1,
|
|
price_id: "monthly",
|
|
quantity: 1,
|
|
},
|
|
});
|
|
expect(testModeRes.status).toBe(200);
|
|
|
|
// Create a second code for the same product and validate; should report already_bought_non_stackable
|
|
const createUrlRes2 = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: {
|
|
customer_type: "user",
|
|
customer_id: userId,
|
|
product_id: "test-product",
|
|
},
|
|
});
|
|
expect(createUrlRes2).toMatchInlineSnapshot(`
|
|
NiceResponse {
|
|
"status": 400,
|
|
"body": {
|
|
"code": "PRODUCT_ALREADY_GRANTED",
|
|
"details": {
|
|
"customer_id": "<stripped UUID>",
|
|
"product_id": "test-product",
|
|
},
|
|
"error": "Customer with ID \\"<stripped UUID>\\" already owns product \\"test-product\\".",
|
|
},
|
|
"headers": Headers {
|
|
"x-stack-known-error": "PRODUCT_ALREADY_GRANTED",
|
|
<some fields may have been hidden>,
|
|
},
|
|
}
|
|
`);
|
|
});
|
|
|
|
it("should include conflicting_products when switching within the same group", async ({ expect }) => {
|
|
await Project.createAndSwitch();
|
|
await Payments.setup();
|
|
await Project.updateConfig({
|
|
payments: {
|
|
testMode: true,
|
|
catalogs: { grp: { displayName: "Group" } },
|
|
products: {
|
|
productA: {
|
|
displayName: "Product A",
|
|
customerType: "user",
|
|
serverOnly: false,
|
|
catalogId: "grp",
|
|
stackable: false,
|
|
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
|
|
includedItems: {},
|
|
},
|
|
productB: {
|
|
displayName: "Product B",
|
|
customerType: "user",
|
|
serverOnly: false,
|
|
catalogId: "grp",
|
|
stackable: false,
|
|
prices: { monthly: { USD: "2000", interval: [1, "month"] } },
|
|
includedItems: {},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const { userId } = await User.create();
|
|
|
|
// Subscribe to productA in test mode
|
|
const resUrlA = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: { customer_type: "user", customer_id: userId, product_id: "productA" },
|
|
});
|
|
expect(resUrlA.status).toBe(200);
|
|
const codeA = (resUrlA.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
|
|
expect(codeA).toBeDefined();
|
|
|
|
const testModeRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
|
|
method: "POST",
|
|
accessType: "admin",
|
|
body: { full_code: codeA, price_id: "monthly", quantity: 1 },
|
|
});
|
|
expect(testModeRes.status).toBe(200);
|
|
|
|
// Now validate code for productB; should report conflict with productA
|
|
const resUrlB = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: { customer_type: "user", customer_id: userId, product_id: "productB" },
|
|
});
|
|
expect(resUrlB.status).toBe(200);
|
|
const codeB = (resUrlB.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
|
|
expect(codeB).toBeDefined();
|
|
|
|
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: { full_code: codeB },
|
|
});
|
|
expect(validateResponse).toMatchInlineSnapshot(`
|
|
NiceResponse {
|
|
"status": 200,
|
|
"body": {
|
|
"already_bought_non_stackable": false,
|
|
"conflicting_products": [
|
|
{
|
|
"display_name": "Product A",
|
|
"product_id": "productA",
|
|
},
|
|
],
|
|
"product": {
|
|
"customer_type": "user",
|
|
"display_name": "Product B",
|
|
"included_items": {},
|
|
"prices": {
|
|
"monthly": {
|
|
"USD": "2000",
|
|
"interval": [
|
|
1,
|
|
"month",
|
|
],
|
|
},
|
|
},
|
|
"server_only": false,
|
|
"stackable": false,
|
|
},
|
|
"project_id": "<stripped UUID>",
|
|
"stripe_account_id": <stripped field 'stripe_account_id'>,
|
|
"test_mode": true,
|
|
},
|
|
"headers": Headers { <some fields may have been hidden> },
|
|
}
|
|
`);
|
|
});
|
|
|
|
it("should reject untrusted return_url and accept trusted return_url", async ({ expect }) => {
|
|
await Project.createAndSwitch();
|
|
await Payments.setup();
|
|
await Project.updateConfig({
|
|
payments: {
|
|
products: {
|
|
"test-product": {
|
|
displayName: "Test Product",
|
|
customerType: "user",
|
|
serverOnly: false,
|
|
stackable: false,
|
|
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
|
|
includedItems: {},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const { userId } = await User.create();
|
|
await Project.updateConfig({
|
|
domains: {
|
|
allowLocalhost: false,
|
|
trustedDomains: {
|
|
'1': { baseUrl: 'https://stack-test.com', handlerPath: '/handler' },
|
|
},
|
|
},
|
|
});
|
|
const createUrlRes = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: { customer_type: "user", customer_id: userId, product_id: "test-product" },
|
|
});
|
|
expect(createUrlRes.status).toBe(200);
|
|
const code = (createUrlRes.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
|
|
expect(code).toBeDefined();
|
|
|
|
const badRes = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: { full_code: code, return_url: "https://malicious.com/callback" },
|
|
});
|
|
expect(badRes).toMatchInlineSnapshot(`
|
|
NiceResponse {
|
|
"status": 400,
|
|
"body": {
|
|
"code": "REDIRECT_URL_NOT_WHITELISTED",
|
|
"error": "Redirect URL not whitelisted. Did you forget to add this domain to the trusted domains list on the Stack Auth dashboard?",
|
|
},
|
|
"headers": Headers {
|
|
"x-stack-known-error": "REDIRECT_URL_NOT_WHITELISTED",
|
|
<some fields may have been hidden>,
|
|
},
|
|
}
|
|
`);
|
|
|
|
const goodRes = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
|
|
method: "POST",
|
|
accessType: "client",
|
|
body: { full_code: code, return_url: "https://stack-test.com/handler" },
|
|
});
|
|
expect(goodRes).toMatchInlineSnapshot(`
|
|
NiceResponse {
|
|
"status": 200,
|
|
"body": {
|
|
"already_bought_non_stackable": false,
|
|
"conflicting_products": [],
|
|
"product": {
|
|
"customer_type": "user",
|
|
"display_name": "Test Product",
|
|
"included_items": {},
|
|
"prices": {
|
|
"monthly": {
|
|
"USD": "1000",
|
|
"interval": [
|
|
1,
|
|
"month",
|
|
],
|
|
},
|
|
},
|
|
"server_only": false,
|
|
"stackable": false,
|
|
},
|
|
"project_id": "<stripped UUID>",
|
|
"stripe_account_id": <stripped field 'stripe_account_id'>,
|
|
"test_mode": true,
|
|
},
|
|
"headers": Headers { <some fields may have been hidden> },
|
|
}
|
|
`);
|
|
});
|