Skip to content

Commit e4c9f3d

Browse files
authored
Payments test mode toggle (#929)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Add test mode toggle for payments, update API and UI, and expand tests for test-mode flows. > > - **Behavior**: > - Added test mode toggle to Project Payments settings in `page-client-catalogs-view.tsx` and `page-client-list-view.tsx`. > - API responses in `validate-code/route.ts` now include `test_mode` flag. > - Purchase page in `page-client.tsx` shows Test mode bypass button when test mode is enabled. > - **API**: > - `test-mode-purchase-session/route.tsx` requires test mode enabled for test-mode purchase sessions, returns 403 otherwise. > - Simplified error message for server-only products in `payments.tsx`. > - **Config**: > - Added `testMode` to payments config schema in `schema.ts` and defaults in `schema.ts`. > - **Tests**: > - Expanded end-to-end tests in `transactions.test.ts` and `purchase-session.test.ts` to cover test-mode flows. > - **Misc**: > - Removed `testModePurchase` method from `admin-interface.ts` and `admin-app-impl.ts`. > > <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> for a1ac7ef. 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 - Test mode toggle added to Project Payments settings; UI reflects and can update live. - API responses for code validation include a test_mode flag. - Purchase page shows a Test mode bypass button only when test mode is enabled; it’s disabled if price/quantity are invalid. - Changes - Test-mode purchase sessions require Test mode enabled; otherwise return 403 with a clear message. - Simpler client-side error message when accessing server-only products from the client. - Chores - Added testMode to payments config defaults and schema. - Tests - End-to-end tests expanded to cover test-mode flows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> <!-- RECURSEML_SUMMARY:START --> ## High-level PR Summary [![Analyze latest changes](https://img.shields.io/badge/Analyze%20latest%20changes-238636?style=plastic)](https://squash-322339097191.europe-west3.run.app/interactive/eada05884195c27340017c23544f302e4a9c3823f4152dcb2fa96aec3ea20b7d/?repo_owner=stack-auth&repo_name=stack-auth&pr_number=929) [![Need help? Join our Discord](https://img.shields.io/badge/Need%20help%3F%20Join%20our%20Discord-5865F2?style=plastic&logo=discord&logoColor=white)](https://discord.gg/n3SsVDAW6U) <!-- RECURSEML_SUMMARY:END -->
1 parent a7fe4b9 commit e4c9f3d

22 files changed

Lines changed: 217 additions & 239 deletions

File tree

apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { purchaseUrlVerificationCodeHandler } from "@/app/api/latest/payments/purchases/verification-code-handler";
22
import { validatePurchaseSession } from "@/lib/payments";
3+
import { getTenancy } from "@/lib/tenancies";
34
import { getStripeForAccount } from "@/lib/stripe";
45
import { getPrismaClientForTenancy } from "@/prisma-client";
56
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
67
import { SubscriptionStatus } from "@prisma/client";
7-
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
8+
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
89
import { addInterval } from "@stackframe/stack-shared/dist/utils/dates";
910
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
1011
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
@@ -14,11 +15,6 @@ export const POST = createSmartRouteHandler({
1415
hidden: true,
1516
},
1617
request: yupObject({
17-
auth: yupObject({
18-
type: adminAuthTypeSchema.defined(),
19-
project: adaptSchema.defined(),
20-
tenancy: adaptSchema.defined(),
21-
}).defined(),
2218
body: yupObject({
2319
full_code: yupString().defined(),
2420
price_id: yupString().defined(),
@@ -29,17 +25,22 @@ export const POST = createSmartRouteHandler({
2925
statusCode: yupNumber().oneOf([200]).defined(),
3026
bodyType: yupString().oneOf(["success"]).defined(),
3127
}),
32-
handler: async ({ auth, body }) => {
28+
handler: async ({ body }) => {
3329
const { full_code, price_id, quantity } = body;
3430
const { data, id: codeId } = await purchaseUrlVerificationCodeHandler.validateCode(full_code);
35-
if (auth.tenancy.id !== data.tenancyId) {
36-
throw new StatusError(400, "Tenancy id does not match value from code data");
31+
32+
const tenancy = await getTenancy(data.tenancyId);
33+
if (!tenancy) {
34+
throw new StackAssertionError("Tenancy not found for test mode purchase session");
35+
}
36+
if (tenancy.config.payments.testMode !== true) {
37+
throw new StatusError(403, "Test mode is not enabled for this project");
3738
}
38-
const prisma = await getPrismaClientForTenancy(auth.tenancy);
39+
const prisma = await getPrismaClientForTenancy(tenancy);
3940

4041
const { selectedPrice, conflictingCatalogSubscriptions } = await validatePurchaseSession({
4142
prisma,
42-
tenancy: auth.tenancy,
43+
tenancy,
4344
codeData: data,
4445
priceId: price_id,
4546
quantity,
@@ -51,7 +52,7 @@ export const POST = createSmartRouteHandler({
5152
if (!selectedPrice.interval) {
5253
await prisma.oneTimePurchase.create({
5354
data: {
54-
tenancyId: auth.tenancy.id,
55+
tenancyId: tenancy.id,
5556
customerId: data.customerId,
5657
customerType: typedToUppercase(data.product.customerType),
5758
productId: data.productId,
@@ -66,13 +67,13 @@ export const POST = createSmartRouteHandler({
6667
if (conflictingCatalogSubscriptions.length > 0) {
6768
const conflicting = conflictingCatalogSubscriptions[0];
6869
if (conflicting.stripeSubscriptionId) {
69-
const stripe = await getStripeForAccount({ tenancy: auth.tenancy });
70+
const stripe = await getStripeForAccount({ tenancy });
7071
await stripe.subscriptions.cancel(conflicting.stripeSubscriptionId);
7172
} else if (conflicting.id) {
7273
await prisma.subscription.update({
7374
where: {
7475
tenancyId_id: {
75-
tenancyId: auth.tenancy.id,
76+
tenancyId: tenancy.id,
7677
id: conflicting.id,
7778
},
7879
},
@@ -83,7 +84,7 @@ export const POST = createSmartRouteHandler({
8384

8485
await prisma.subscription.create({
8586
data: {
86-
tenancyId: auth.tenancy.id,
87+
tenancyId: tenancy.id,
8788
customerId: data.customerId,
8889
customerType: typedToUppercase(data.product.customerType),
8990
status: "active",
@@ -99,7 +100,7 @@ export const POST = createSmartRouteHandler({
99100
});
100101
}
101102
await purchaseUrlVerificationCodeHandler.revokeCode({
102-
tenancy: auth.tenancy,
103+
tenancy,
103104
id: codeId,
104105
});
105106

apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const POST = createSmartRouteHandler({
3939
product_id: yupString().defined(),
4040
display_name: yupString().defined(),
4141
}).defined()).defined(),
42+
test_mode: yupBoolean().defined(),
4243
}).defined(),
4344
}),
4445
async handler({ body }) {
@@ -102,6 +103,7 @@ export const POST = createSmartRouteHandler({
102103
project_id: tenancy.project.id,
103104
already_bought_non_stackable: alreadyBoughtNonStackable,
104105
conflicting_products: conflictingCatalogProducts,
106+
test_mode: tenancy.config.payments.testMode === true,
105107
},
106108
};
107109
},

apps/backend/src/lib/payments.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ export async function ensureProductIdOrInlineProduct(
3131
}
3232
if (productId) {
3333
const product = getOrUndefined(tenancy.config.payments.products, productId);
34-
if (!product || (product.serverOnly && accessType === "client")) {
34+
if (!product) {
3535
throw new KnownErrors.ProductDoesNotExist(productId, accessType);
3636
}
37+
if (product.serverOnly && accessType === "client") {
38+
throw new StatusError(400, "This product is marked as server-only and cannot be accessed client side!");
39+
}
3740
return product;
3841
} else {
3942
if (!inlineProduct) {

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/item-dialog.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ export function ItemDialog({
9191
id="item-id"
9292
value={itemId}
9393
onChange={(e) => {
94-
setItemId(e.target.value);
94+
const nextValue = e.target.value.toLowerCase();
95+
setItemId(nextValue);
9596
if (errors.itemId) {
9697
setErrors(prev => {
9798
const newErrors = { ...prev };

0 commit comments

Comments
 (0)