From d3919ec573311bfec914d1b9161279d6519766f6 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 3 Oct 2025 16:01:09 -0700 Subject: [PATCH 01/12] return_url --- .../purchases/create-purchase-url/route.ts | 4 +++ .../payments/purchases/validate-code/route.ts | 2 ++ .../src/route-handlers/smart-response.tsx | 2 +- .../projects/page-client.tsx | 7 ++-- .../(main)/purchase/[code]/page-client.tsx | 9 ++++- .../(main)/purchase/return/page-client.tsx | 19 +++++++++-- .../src/components/payments/checkout.tsx | 14 +++++--- .../v1/payments/create-purchase-url.test.ts | 10 ++++-- apps/e2e/tests/js/payments.test.ts | 34 +++++++++++++++++++ .../src/interface/client-interface.ts | 3 +- .../apps/implementations/client-app-impl.ts | 4 +-- .../src/lib/stack-app/customers/index.ts | 4 +-- 12 files changed, 93 insertions(+), 19 deletions(-) diff --git a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts index cdd4faa16b..bbccc0bff0 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts @@ -24,6 +24,7 @@ export const POST = createSmartRouteHandler({ customer_id: yupString().defined(), offer_id: yupString().optional(), offer_inline: inlineOfferSchema.optional(), + return_url: yupString().optional(), }), }), response: yupObject({ @@ -77,6 +78,9 @@ export const POST = createSmartRouteHandler({ const fullCode = `${tenancy.id}_${code}`; const url = new URL(`/purchase/${fullCode}`, getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL")); + if (req.body.return_url) { + url.searchParams.set("return_url", req.body.return_url); + } return { statusCode: 200, diff --git a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts index 1807b101f7..8a722d48e0 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts @@ -31,6 +31,7 @@ export const POST = createSmartRouteHandler({ offer: offerDataSchema, stripe_account_id: yupString().defined(), project_id: yupString().defined(), + return_url: yupString().optional(), already_bought_non_stackable: yupBoolean().defined(), conflicting_group_offers: yupArray(yupObject({ offer_id: yupString().defined(), @@ -94,6 +95,7 @@ export const POST = createSmartRouteHandler({ offer: offerData, stripe_account_id: verificationCode.data.stripeAccountId, project_id: tenancy.project.id, + return_url: verificationCode.data.returnUrl, already_bought_non_stackable: alreadyBoughtNonStackable, conflicting_group_offers: conflictingGroupOffers, }, diff --git a/apps/backend/src/route-handlers/smart-response.tsx b/apps/backend/src/route-handlers/smart-response.tsx index e1116fb6b5..9501410ab1 100644 --- a/apps/backend/src/route-handlers/smart-response.tsx +++ b/apps/backend/src/route-handlers/smart-response.tsx @@ -30,7 +30,7 @@ export type SmartResponse = { } | { bodyType: "binary", - body: ArrayBuffer, + body: BodyInit, } | { bodyType: "success", diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index 2ab066e7fa..3f23df1eef 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -134,8 +134,11 @@ function TeamAddUserDialog(props: { const onSubmit = async (values: yup.InferType) => { if (users.length + 1 > quantity) { alert("You have reached the maximum number of dashboard admins. Please upgrade your plan to add more admins."); - const checkoutUrl = await props.team.createCheckoutUrl({ offerId: "team" }); - window.open(checkoutUrl, "_blank", "noopener"); + const checkoutUrl = await props.team.createCheckoutUrl({ + offerId: "team", + returnUrl: window.location.href, + }); + window.location.assign(checkoutUrl); return "prevent-close-and-prevent-reset"; } await props.onSubmit(values.email); diff --git a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx index 5100d0c2b5..2144d656da 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -10,6 +10,7 @@ import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, Input, Skeleton, Typography } from "@stackframe/stack-ui"; import { ArrowRight, Minus, Plus } from "lucide-react"; +import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; @@ -30,8 +31,10 @@ export default function PageClient({ code }: { code: string }) { const [error, setError] = useState(null); const [selectedPriceId, setSelectedPriceId] = useState(null); const [quantityInput, setQuantityInput] = useState("1"); + const searchParams = useSearchParams(); const user = useUser({ projectIdMustMatch: "internal" }); const [adminApp, setAdminApp] = useState(); + const returnUrl = searchParams.get("return_url"); useEffect(() => { if (!user || !data) return; @@ -138,8 +141,11 @@ export default function PageClient({ code }: { code: string }) { const url = new URL(`/purchase/return`, window.location.origin); url.searchParams.set("bypass", "1"); url.searchParams.set("purchase_full_code", code); + if (returnUrl) { + url.searchParams.set("return_url", returnUrl); + } window.location.assign(url.toString()); - }, [code, adminApp, selectedPriceId, quantityNumber, isTooLarge]); + }, [code, adminApp, selectedPriceId, quantityNumber, isTooLarge, returnUrl]); return (
@@ -281,6 +287,7 @@ export default function PageClient({ code }: { code: string }) { fullCode={code} stripeAccountId={data.stripe_account_id} setupSubscription={setupSubscription} + returnUrl={returnUrl ?? undefined} disabled={quantityNumber < 1 || isTooLarge || data.already_bought_non_stackable === true} /> diff --git a/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx index 61230a2f15..912e65f013 100644 --- a/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx @@ -6,6 +6,7 @@ import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises" import { Typography } from "@stackframe/stack-ui"; import { loadStripe } from "@stripe/stripe-js"; import { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; type Props = { redirectStatus?: string, @@ -25,10 +26,18 @@ const stripePublicKey = getPublicEnvVar("NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KE export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFullCode, bypass }: Props) { const [state, setState] = useState({ kind: "loading" }); + const searchParams = useSearchParams(); + const returnUrl = searchParams.get("return_url"); const updateViewState = useCallback(async (): Promise => { try { if (bypass === "1") { + if (returnUrl) { + window.location.assign(returnUrl); + } + const message = returnUrl + ? "Bypassed in test mode. No payment processed. You will be redirected shortly." + : "Bypassed in test mode. No payment processed."; setState({ kind: "success", message: "Bypassed in test mode. No payment processed." }); return; } @@ -40,7 +49,13 @@ export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFu const lastErrorMessage = result.paymentIntent?.last_payment_error?.message; if (status === "succeeded") { - setState({ kind: "success", message: "Payment succeeded. You can close this page." }); + if (returnUrl) { + window.location.assign(returnUrl); + } + const message = returnUrl + ? "Payment succeeded. You will be redirected shortly." + : "Payment succeeded. You can close this page."; + setState({ kind: "success", message }); return; } if (status === "processing") { @@ -64,7 +79,7 @@ export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFu const message = e instanceof Error ? e.message : "Unexpected error retrieving payment."; setState({ kind: "error", message }); } - }, [clientSecret, stripeAccountId, bypass]); + }, [clientSecret, stripeAccountId, bypass, returnUrl]); useEffect(() => { runAsynchronously(updateViewState()); diff --git a/apps/dashboard/src/components/payments/checkout.tsx b/apps/dashboard/src/components/payments/checkout.tsx index badd2ee50e..cf2862f259 100644 --- a/apps/dashboard/src/components/payments/checkout.tsx +++ b/apps/dashboard/src/components/payments/checkout.tsx @@ -21,10 +21,11 @@ type Props = { setupSubscription: () => Promise, stripeAccountId: string, fullCode: string, + returnUrl?: string, disabled?: boolean, }; -export function CheckoutForm({ setupSubscription, stripeAccountId, fullCode, disabled }: Props) { +export function CheckoutForm({ setupSubscription, stripeAccountId, fullCode, returnUrl, disabled }: Props) { const stripe = useStripe(); const elements = useElements(); const [message, setMessage] = useState(null); @@ -39,15 +40,18 @@ export function CheckoutForm({ setupSubscription, stripeAccountId, fullCode, dis } const clientSecret = await setupSubscription(); - const returnUrl = new URL(`/purchase/return`, window.location.origin); - returnUrl.searchParams.set("stripe_account_id", stripeAccountId); - returnUrl.searchParams.set("purchase_full_code", fullCode); + const stripeReturnUrl = new URL(`/purchase/return`, window.location.origin); + stripeReturnUrl.searchParams.set("stripe_account_id", stripeAccountId); + stripeReturnUrl.searchParams.set("purchase_full_code", fullCode); + if (returnUrl) { + stripeReturnUrl.searchParams.set("return_url", returnUrl); + } const { error } = await stripe.confirmPayment({ elements, clientSecret, confirmParams: { - return_url: returnUrl.toString(), + return_url: stripeReturnUrl.toString(), }, }) as { error?: StripeError }; diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts index c09c22c5ac..3da01a3404 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts @@ -1,6 +1,6 @@ -import { it } from "../../../../../helpers"; -import { Auth, Project, User, niceBackendFetch, Payments } from "../../../../backend-helpers"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; +import { it } from "../../../../../helpers"; +import { Auth, niceBackendFetch, Payments, Project, User } from "../../../../backend-helpers"; it("should not be able to create purchase URL without offer_id or offer_inline", async ({ expect }) => { await Project.createAndSwitch(); @@ -309,9 +309,13 @@ it("should allow valid offer_id", async ({ expect }) => { customer_type: "user", customer_id: userId, offer_id: "test-offer", + return_url: "http://stack-test.localhost/after-purchase", }, }); expect(response.status).toBe(200); const body = response.body as { url: string }; - expect(body.url).toMatch(/^https?:\/\/localhost:8101\/purchase\/[a-z0-9-_]+$/); + expect(body.url).toMatch(/^https?:\/\/localhost:8101\/purchase\/[a-z0-9-_]+\?return_url=/); + const urlObj = new URL(body.url); + const returnUrl = urlObj.searchParams.get("return_url"); + expect(returnUrl).toBe("http://stack-test.localhost/after-purchase"); }); diff --git a/apps/e2e/tests/js/payments.test.ts b/apps/e2e/tests/js/payments.test.ts index e4586bd438..f7a257be4f 100644 --- a/apps/e2e/tests/js/payments.test.ts +++ b/apps/e2e/tests/js/payments.test.ts @@ -1,6 +1,40 @@ import { it } from "../helpers"; import { createApp } from "./js-helpers"; +it("createCheckoutUrl supports optional returnUrl and embeds it", async ({ expect }) => { + const { clientApp, adminApp } = await createApp({ config: {} }); + const project = await adminApp.getProject(); + const adminRes = await (adminApp as any)[Symbol.for("StackAuth--DO-NOT-USE-OR-YOU-WILL-BE-FIRED--StackAppInternals")].sendRequest( + "/internal/payments/setup", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}) + }, "admin" + ); + expect(adminRes.ok).toBe(true); + + await project.updateConfig({ + "payments.offers.test-offer": { + displayName: "Test Offer", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { monthly: { USD: "1000", interval: [1, "month"] } }, + includedItems: {}, + }, + }); + await clientApp.signUpWithCredential({ email: "checkout-return@test.com", password: "password", verificationCallbackUrl: "http://localhost:3000" }); + await clientApp.signInWithCredential({ email: "checkout-return@test.com", password: "password" }); + const user = await clientApp.getUser(); + if (!user) throw new Error("User not found"); + + const url = await user.createCheckoutUrl({ offerId: "test-offer", returnUrl: "http://stack-test.localhost/after" }); + expect(url).toMatch(/^https?:\/\/localhost:8101\/purchase\/[a-z0-9-_]+\?return_url=/); + const urlObj = new URL(url); + expect(urlObj.searchParams.get("return_url")).toBe("http://stack-test.localhost/after"); +}, { timeout: 60_000 }); + it("returns default item quantity for a team", async ({ expect }) => { const { clientApp, adminApp } = await createApp({ config: { diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index 1a12ee5ca3..2aaf73b4db 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -1779,6 +1779,7 @@ export class StackClientInterface { customer_id: string, offerIdOrInline: string | yup.InferType, session: InternalSession | null, + returnUrl?: string, ): Promise { const offerBody = typeof offerIdOrInline === "string" ? { offer_id: offerIdOrInline } : @@ -1790,7 +1791,7 @@ export class StackClientInterface { headers: { "content-type": "application/json", }, - body: JSON.stringify({ customer_type, customer_id, ...offerBody }), + body: JSON.stringify({ customer_type, customer_id, ...offerBody, return_url: returnUrl }), }, session ); diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 22a7d61098..8f314170a6 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -1272,8 +1272,8 @@ export class _StackClientAppImplIncomplete = readonly id: string, createCheckoutUrl(options: ( - | { offerId: string } - | (IsServer extends true ? { offer: InlineOffer } : never) + | { offerId: string, returnUrl?: string } + | (IsServer extends true ? { offer: InlineOffer, returnUrl?: string } : never) )): Promise, } & AsyncStoreProperty< From 90f46dc3145744192e99a559687fb565a93d9f0c Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 3 Oct 2025 16:13:50 -0700 Subject: [PATCH 02/12] server customer fn --- .../apps/implementations/server-app-impl.ts | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index 6f4991c348..74a53dc59f 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -21,10 +21,10 @@ import { useMemo } from "react"; // THIS_LINE_PLATFORM react-like import * as yup from "yup"; import { constructRedirectUrl } from "../../../../utils/url"; import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud, apiKeyUpdateOptionsToCrud } from "../../api-keys"; -import { GetCurrentUserOptions, HandlerUrls, OAuthScopesOnSignIn, TokenStoreInit, ConvexCtx } from "../../common"; +import { ConvexCtx, GetCurrentUserOptions, HandlerUrls, OAuthScopesOnSignIn, TokenStoreInit } from "../../common"; import { OAuthConnection } from "../../connected-accounts"; import { ServerContactChannel, ServerContactChannelCreateOptions, ServerContactChannelUpdateOptions, serverContactChannelCreateOptionsToCrud, serverContactChannelUpdateOptionsToCrud } from "../../contact-channels"; -import { InlineOffer, ServerItem } from "../../customers"; +import { Customer, InlineOffer, ServerItem } from "../../customers"; import { DataVaultStore } from "../../data-vault"; import { SendEmailOptions } from "../../email"; import { NotificationCategory } from "../../notification-categories"; @@ -193,6 +193,27 @@ export class _StackServerAppImplIncomplete, "id"> { + const app = this; + const cache = type === "user" ? app._serverUserItemsCache : app._serverTeamItemsCache; + return { + async getItem(itemId: string) { + const result = Result.orThrow(await cache.getOrWait([userIdOrTeamId, itemId], "write-only")); + return app._serverItemFromCrud({ type, id: userIdOrTeamId }, result); + }, + // IF_PLATFORM react-like + useItem(itemId: string) { + const result = useAsyncCache(cache, [userIdOrTeamId, itemId] as const, `${type}.useItem()`); + return useMemo(() => app._serverItemFromCrud({ type, id: userIdOrTeamId }, result), [result]); + }, + // END_PLATFORM + async createCheckoutUrl(options: { offerId: string, returnUrl?: string } | { offer: InlineOffer, returnUrl?: string }) { + const offerIdOrInline = "offerId" in options ? options.offerId : options.offer; + return await app._interface.createCheckoutUrl(type, userIdOrTeamId, offerIdOrInline, null, (options as any).returnUrl); + }, + }; + } + private async _updateServerUser(userId: string, update: ServerUserUpdateOptions): Promise { const result = await this._interface.updateServerUser(userId, serverUserUpdateOptionsToCrud(update)); await this._refreshUsers(); @@ -653,20 +674,7 @@ export class _StackServerAppImplIncomplete p.id === id) ?? null; }, - async createCheckoutUrl(options: { offerId: string } | { offer: InlineOffer }) { - const offerIdOrInline = "offerId" in options ? options.offerId : options.offer; - return await app._interface.createCheckoutUrl("user", crud.id, offerIdOrInline, null); - }, - async getItem(itemId: string) { - const result = Result.orThrow(await app._serverUserItemsCache.getOrWait([crud.id, itemId], "write-only")); - return app._serverItemFromCrud({ type: "user", id: crud.id }, result); - }, - // IF_PLATFORM react-like - useItem(itemId: string) { - const result = useAsyncCache(app._serverUserItemsCache, [crud.id, itemId] as const, "user.useItem()"); - return useMemo(() => app._serverItemFromCrud({ type: "user", id: crud.id }, result), [result]); - }, - // END_PLATFORM + ...app._createServerCustomer(crud.id, "user"), }; } @@ -782,20 +790,7 @@ export class _StackServerAppImplIncomplete app._serverItemFromCrud({ type: "team", id: crud.id }, result), [result]); - }, - // END_PLATFORM - async createCheckoutUrl(options: { offerId: string } | { offer: InlineOffer }) { - const offerIdOrInline = "offerId" in options ? options.offerId : options.offer; - return await app._interface.createCheckoutUrl("team", crud.id, offerIdOrInline, null); - }, + ...app._createServerCustomer(crud.id, "team"), }; } From 5d7547f40fada91e71849602e5609068861f39f3 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 3 Oct 2025 16:16:34 -0700 Subject: [PATCH 03/12] small fixes --- .../app/api/latest/payments/purchases/validate-code/route.ts | 2 -- apps/dashboard/src/app/(main)/purchase/return/page-client.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts index 8a722d48e0..1807b101f7 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts @@ -31,7 +31,6 @@ export const POST = createSmartRouteHandler({ offer: offerDataSchema, stripe_account_id: yupString().defined(), project_id: yupString().defined(), - return_url: yupString().optional(), already_bought_non_stackable: yupBoolean().defined(), conflicting_group_offers: yupArray(yupObject({ offer_id: yupString().defined(), @@ -95,7 +94,6 @@ export const POST = createSmartRouteHandler({ offer: offerData, stripe_account_id: verificationCode.data.stripeAccountId, project_id: tenancy.project.id, - return_url: verificationCode.data.returnUrl, already_bought_non_stackable: alreadyBoughtNonStackable, conflicting_group_offers: conflictingGroupOffers, }, diff --git a/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx index 912e65f013..0a1a0b0e75 100644 --- a/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx @@ -38,7 +38,7 @@ export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFu const message = returnUrl ? "Bypassed in test mode. No payment processed. You will be redirected shortly." : "Bypassed in test mode. No payment processed."; - setState({ kind: "success", message: "Bypassed in test mode. No payment processed." }); + setState({ kind: "success", message }); return; } const stripe = await loadStripe(stripePublicKey, { stripeAccount: stripeAccountId }); From 8a3a1904875024485115e34cf433f6e368eddeb2 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 3 Oct 2025 16:30:18 -0700 Subject: [PATCH 04/12] small fixes --- .../payments/purchases/create-purchase-url/route.ts | 4 ++-- apps/backend/src/route-handlers/smart-response.tsx | 2 +- apps/e2e/tests/js/payments.test.ts | 12 ++---------- .../apps/implementations/server-app-impl.ts | 2 +- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts index bbccc0bff0..25ec362b7f 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts @@ -4,7 +4,7 @@ import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { CustomerType } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; -import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, urlSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; @@ -24,7 +24,7 @@ export const POST = createSmartRouteHandler({ customer_id: yupString().defined(), offer_id: yupString().optional(), offer_inline: inlineOfferSchema.optional(), - return_url: yupString().optional(), + return_url: urlSchema.optional(), }), }), response: yupObject({ diff --git a/apps/backend/src/route-handlers/smart-response.tsx b/apps/backend/src/route-handlers/smart-response.tsx index 9501410ab1..e1116fb6b5 100644 --- a/apps/backend/src/route-handlers/smart-response.tsx +++ b/apps/backend/src/route-handlers/smart-response.tsx @@ -30,7 +30,7 @@ export type SmartResponse = { } | { bodyType: "binary", - body: BodyInit, + body: ArrayBuffer, } | { bodyType: "success", diff --git a/apps/e2e/tests/js/payments.test.ts b/apps/e2e/tests/js/payments.test.ts index f7a257be4f..c280f42592 100644 --- a/apps/e2e/tests/js/payments.test.ts +++ b/apps/e2e/tests/js/payments.test.ts @@ -4,16 +4,7 @@ import { createApp } from "./js-helpers"; it("createCheckoutUrl supports optional returnUrl and embeds it", async ({ expect }) => { const { clientApp, adminApp } = await createApp({ config: {} }); const project = await adminApp.getProject(); - const adminRes = await (adminApp as any)[Symbol.for("StackAuth--DO-NOT-USE-OR-YOU-WILL-BE-FIRED--StackAppInternals")].sendRequest( - "/internal/payments/setup", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({}) - }, "admin" - ); - expect(adminRes.ok).toBe(true); - + await adminApp.setupPayments(); await project.updateConfig({ "payments.offers.test-offer": { displayName: "Test Offer", @@ -24,6 +15,7 @@ it("createCheckoutUrl supports optional returnUrl and embeds it", async ({ expec includedItems: {}, }, }); + await clientApp.signUpWithCredential({ email: "checkout-return@test.com", password: "password", verificationCallbackUrl: "http://localhost:3000" }); await clientApp.signInWithCredential({ email: "checkout-return@test.com", password: "password" }); const user = await clientApp.getUser(); diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index 74a53dc59f..0ad1aaa6e8 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -209,7 +209,7 @@ export class _StackServerAppImplIncomplete Date: Fri, 3 Oct 2025 19:41:50 -0700 Subject: [PATCH 05/12] fix ungrouped include by default --- apps/backend/src/lib/payments.test.tsx | 124 ++++++++++++++++++++++++- apps/backend/src/lib/payments.tsx | 32 ++++++- 2 files changed, 151 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/lib/payments.test.tsx b/apps/backend/src/lib/payments.test.tsx index 7db9cb30aa..caa61f7bc0 100644 --- a/apps/backend/src/lib/payments.test.tsx +++ b/apps/backend/src/lib/payments.test.tsx @@ -1,6 +1,6 @@ import type { PrismaClientTransaction } from '@/prisma-client'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getItemQuantityForCustomer, validatePurchaseSession } from './payments'; +import { getItemQuantityForCustomer, getSubscriptions, validatePurchaseSession } from './payments'; import type { Tenancy } from './tenancies'; function createMockPrisma(overrides: Partial = {}): PrismaClientTransaction { @@ -717,6 +717,38 @@ describe('getItemQuantityForCustomer - subscriptions', () => { expect(qty).toBe(8); vi.useRealTimers(); }); + + it('ungrouped include-by-default provides item quantity without db subscription', async () => { + const now = new Date('2025-02-10T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'defaultItemUngrouped'; + + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'UDF', customerType: 'user' } }, + groups: {}, + offers: { + offFreeUngrouped: { + displayName: 'Free Ungrouped', + groupId: undefined, + customerType: 'user', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: 'include-by-default', + includedItems: { [itemId]: { quantity: 5, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { findMany: async () => [] }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'u1', customerType: 'user' }); + expect(qty).toBe(5); + vi.useRealTimers(); + }); }); @@ -933,3 +965,93 @@ describe('combined sources - one-time purchases + manual changes + subscriptions }); }); + +describe('getSubscriptions - defaults behavior', () => { + it('includes ungrouped include-by-default offers in subscriptions', async () => { + const tenancy = createMockTenancy({ + items: {}, + groups: {}, + offers: { + freeUngrouped: { + displayName: 'Free', + groupId: undefined, + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: 'include-by-default', + includedItems: {}, + isAddOnTo: false, + }, + paidUngrouped: { + displayName: 'Paid', + groupId: undefined, + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: {}, + includedItems: {}, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { findMany: async () => [] }, + } as any); + + const subs = await getSubscriptions({ + prisma, + tenancy, + customerType: 'custom', + customerId: 'c-1', + }); + + const ids = subs.map(s => s.offerId); + expect(ids).toContain('freeUngrouped'); + }); + + it('throws error when multiple include-by-default offers exist in same group', async () => { + const tenancy = createMockTenancy({ + items: {}, + groups: { g1: { displayName: 'G1' } }, + offers: { + g1FreeA: { + displayName: 'Free A', + groupId: 'g1', + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: 'include-by-default', + includedItems: {}, + isAddOnTo: false, + }, + g1FreeB: { + displayName: 'Free B', + groupId: 'g1', + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: 'include-by-default', + includedItems: {}, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { findMany: async () => [] }, + } as any); + + await expect(getSubscriptions({ + prisma, + tenancy, + customerType: 'custom', + customerId: 'c-1', + })).rejects.toThrowError('Multiple include-by-default offers configured in the same group'); + }); +}); + diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index c9f4b3a5f5..12255167c5 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -301,12 +301,19 @@ export async function getSubscriptions(options: { for (const groupId of Object.keys(groups)) { if (groupsWithDbSubscriptions.has(groupId)) continue; const offersInGroup = typedEntries(offers).filter(([_, offer]) => offer.groupId === groupId); - const defaultGroupOffer = offersInGroup.find(([_, offer]) => offer.prices === "include-by-default"); - if (defaultGroupOffer) { + const defaultGroupOffers = offersInGroup.filter(([_, offer]) => offer.prices === "include-by-default"); + if (defaultGroupOffers.length > 1) { + throw new StackAssertionError( + "Multiple include-by-default offers configured in the same group", + { groupId, offerIds: defaultGroupOffers.map(([id]) => id) }, + ); + } + if (defaultGroupOffers.length === 1) { + const [offerId, offer] = defaultGroupOffers[0]; subscriptions.push({ id: null, - offerId: defaultGroupOffer[0], - offer: defaultGroupOffer[1], + offerId, + offer, quantity: 1, currentPeriodStart: DEFAULT_OFFER_START_DATE, currentPeriodEnd: null, @@ -317,6 +324,23 @@ export async function getSubscriptions(options: { } } + const ungroupedDefaults = typedEntries(offers).filter(([id, offer]) => ( + offer.groupId === undefined && offer.prices === "include-by-default" && !subscriptions.some((s) => s.offerId === id) + )); + for (const [offerId, offer] of ungroupedDefaults) { + subscriptions.push({ + id: null, + offerId, + offer, + quantity: 1, + currentPeriodStart: DEFAULT_OFFER_START_DATE, + currentPeriodEnd: null, + status: SubscriptionStatus.active, + createdAt: DEFAULT_OFFER_START_DATE, + stripeSubscriptionId: null, + }); + } + return subscriptions; } From 7e828ea0084da46c9a7d522a8ac60acfe06ff434 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 3 Oct 2025 20:10:56 -0700 Subject: [PATCH 06/12] fix one-time-stackable --- apps/backend/src/lib/payments.test.tsx | 151 +++++++++++++++++++++++++ apps/backend/src/lib/payments.tsx | 2 +- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/lib/payments.test.tsx b/apps/backend/src/lib/payments.test.tsx index caa61f7bc0..afbd085961 100644 --- a/apps/backend/src/lib/payments.test.tsx +++ b/apps/backend/src/lib/payments.test.tsx @@ -912,6 +912,157 @@ describe('validatePurchaseSession - one-time purchase rules', () => { expect(res.groupId).toBe('g1'); expect(res.conflictingGroupSubscriptions.length).toBe(0); }); + + it('allows duplicate one-time purchase for same offerId when offer is stackable', async () => { + const tenancy = createMockTenancy({ items: {}, offers: {}, groups: {} }); + const prisma = createMockPrisma({ + oneTimePurchase: { + findMany: async () => [ + { offerId: 'offer-stackable', offer: { groupId: undefined }, quantity: 1, createdAt: new Date('2025-01-01T00:00:00.000Z') }, + ], + }, + subscription: { findMany: async () => [] }, + } as any); + + const res = await validatePurchaseSession({ + prisma, + tenancy, + codeData: { + tenancyId: tenancy.id, + customerId: 'cust-1', + offerId: 'offer-stackable', + offer: { + displayName: 'Stackable Offer', + groupId: undefined, + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: true, + prices: 'include-by-default', + includedItems: {}, + isAddOnTo: false, + }, + }, + priceId: 'price-any', + quantity: 2, + }); + + expect(res.groupId).toBeUndefined(); + expect(res.conflictingGroupSubscriptions.length).toBe(0); + }); + + it('blocks when subscription for same offer exists and offer is not stackable', async () => { + const tenancy = createMockTenancy({ + items: {}, + groups: {}, + offers: { + 'offer-sub': { + displayName: 'Non-stackable Offer', + groupId: undefined, + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: {}, + includedItems: {}, + isAddOnTo: false, + }, + }, + }); + const prisma = createMockPrisma({ + oneTimePurchase: { findMany: async () => [] }, + subscription: { + findMany: async () => [{ + offerId: 'offer-sub', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + }], + }, + } as any); + + await expect(validatePurchaseSession({ + prisma, + tenancy, + codeData: { + tenancyId: tenancy.id, + customerId: 'cust-1', + offerId: 'offer-sub', + offer: { + displayName: 'Non-stackable Offer', + groupId: undefined, + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: 'include-by-default', + includedItems: {}, + isAddOnTo: false, + }, + }, + priceId: 'price-any', + quantity: 1, + })).rejects.toThrowError('Customer already has a subscription for this offer; this offer is not stackable'); + }); + + it('allows when subscription for same offer exists and offer is stackable', async () => { + const tenancy = createMockTenancy({ + items: {}, + groups: {}, + offers: { + 'offer-sub-stackable': { + displayName: 'Stackable Offer', + groupId: undefined, + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: true, + prices: {}, + includedItems: {}, + isAddOnTo: false, + }, + }, + }); + const prisma = createMockPrisma({ + oneTimePurchase: { findMany: async () => [] }, + subscription: { + findMany: async () => [{ + offerId: 'offer-sub-stackable', + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + }], + }, + } as any); + + const res = await validatePurchaseSession({ + prisma, + tenancy, + codeData: { + tenancyId: tenancy.id, + customerId: 'cust-1', + offerId: 'offer-sub-stackable', + offer: { + displayName: 'Stackable Offer', + groupId: undefined, + customerType: 'custom', + freeTrial: undefined, + serverOnly: false, + stackable: true, + prices: 'include-by-default', + includedItems: {}, + isAddOnTo: false, + }, + }, + priceId: 'price-any', + quantity: 2, + }); + + expect(res.groupId).toBeUndefined(); + expect(res.conflictingGroupSubscriptions.length).toBe(0); + }); }); describe('combined sources - one-time purchases + manual changes + subscriptions', () => { diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 12255167c5..b6ca85580f 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -433,7 +433,7 @@ export async function validatePurchaseSession(options: { }, }); - if (codeData.offerId && existingOneTimePurchases.some((p) => p.offerId === codeData.offerId)) { + if (codeData.offerId && existingOneTimePurchases.some((p) => p.offerId === codeData.offerId && offer.stackable !== true)) { throw new StatusError(400, "Customer already has a one-time purchase for this offer"); } From b22580a829c107bf898143d37afb820271155f82 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 6 Oct 2025 09:44:08 -0700 Subject: [PATCH 07/12] fix type issue --- apps/e2e/tests/js/payments.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/e2e/tests/js/payments.test.ts b/apps/e2e/tests/js/payments.test.ts index c280f42592..6939b04ecb 100644 --- a/apps/e2e/tests/js/payments.test.ts +++ b/apps/e2e/tests/js/payments.test.ts @@ -21,7 +21,7 @@ it("createCheckoutUrl supports optional returnUrl and embeds it", async ({ expec const user = await clientApp.getUser(); if (!user) throw new Error("User not found"); - const url = await user.createCheckoutUrl({ offerId: "test-offer", returnUrl: "http://stack-test.localhost/after" }); + const url = await user.createCheckoutUrl({ productId: "test-offer", returnUrl: "http://stack-test.localhost/after" }); expect(url).toMatch(/^https?:\/\/localhost:8101\/purchase\/[a-z0-9-_]+\?return_url=/); const urlObj = new URL(url); expect(urlObj.searchParams.get("return_url")).toBe("http://stack-test.localhost/after"); From 259529b22a34bbef6060ed2040a33f0a3d4fff26 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 6 Oct 2025 11:31:10 -0700 Subject: [PATCH 08/12] validate return url --- .../purchases/create-purchase-url/route.ts | 4 + .../payments/purchases/validate-code/route.ts | 48 +++++++++- .../(main)/purchase/[code]/page-client.tsx | 7 +- .../(main)/purchase/return/page-client.tsx | 36 +++++--- .../v1/payments/create-purchase-url.test.ts | 49 +++++++++++ .../api/v1/payments/validate-code.test.ts | 88 +++++++++++++++++++ 6 files changed, 215 insertions(+), 17 deletions(-) diff --git a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts index f67c5d546e..c09a0708fe 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts @@ -1,4 +1,5 @@ import { ensureProductIdOrInlineProduct } from "@/lib/payments"; +import { validateRedirectUrl } from "@/lib/redirect-urls"; import { getStripeForAccount } from "@/lib/stripe"; import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -79,6 +80,9 @@ export const POST = createSmartRouteHandler({ const fullCode = `${tenancy.id}_${code}`; const url = new URL(`/purchase/${fullCode}`, getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL")); if (req.body.return_url) { + if (!validateRedirectUrl(req.body.return_url, tenancy)) { + throw new KnownErrors.RedirectUrlNotWhitelisted(); + } url.searchParams.set("return_url", req.body.return_url); } diff --git a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts index 12fda88c56..137722e1ae 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts @@ -1,8 +1,10 @@ import { getSubscriptions, isActiveSubscription } from "@/lib/payments"; +import { validateRedirectUrl } from "@/lib/redirect-urls"; import { getTenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { inlineProductSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { inlineProductSchema, urlSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { filterUndefined, getOrUndefined, typedEntries, typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects"; @@ -22,6 +24,7 @@ export const POST = createSmartRouteHandler({ request: yupObject({ body: yupObject({ full_code: yupString().defined(), + return_url: urlSchema.optional(), }), }), response: yupObject({ @@ -44,6 +47,9 @@ export const POST = createSmartRouteHandler({ if (!tenancy) { throw new StackAssertionError(`No tenancy found for given tenancyId`); } + if (body.return_url && !validateRedirectUrl(body.return_url, tenancy)) { + throw new KnownErrors.RedirectUrlNotWhitelisted(); + } const product = verificationCode.data.product; const productData: yup.InferType = { display_name: product.displayName ?? "Product", @@ -100,3 +106,43 @@ export const POST = createSmartRouteHandler({ }; }, }); + + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + query: yupObject({ + full_code: yupString().defined(), + return_url: urlSchema.optional(), + }), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + valid: yupBoolean().defined(), + }).defined(), + }), + async handler({ query }) { + const tenancyId = query.full_code.split("_")[0]; + if (!tenancyId) { + throw new KnownErrors.VerificationCodeNotFound(); + } + const tenancy = await getTenancy(tenancyId); + if (!tenancy) { + throw new KnownErrors.VerificationCodeNotFound(); + } + if (query.return_url && !validateRedirectUrl(query.return_url, tenancy)) { + throw new KnownErrors.RedirectUrlNotWhitelisted(); + } + return { + statusCode: 200, + bodyType: "json", + body: { + valid: true, + }, + }; + }, +}); diff --git a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx index c3f17c2f91..dc66bbb18e 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -95,7 +95,10 @@ export default function PageClient({ code }: { code: string }) { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ full_code: code }), + body: JSON.stringify({ + full_code: code, + return_url: returnUrl ?? undefined, + }), }); if (!response.ok) { throw new Error('Failed to validate code'); @@ -106,7 +109,7 @@ export default function PageClient({ code }: { code: string }) { const firstPriceId = Object.keys(result.product.prices)[0]; setSelectedPriceId(firstPriceId); } - }, [code]); + }, [code, returnUrl]); useEffect(() => { setLoading(true); diff --git a/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx index 0a1a0b0e75..2730cfeb70 100644 --- a/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx @@ -2,11 +2,12 @@ import { StyledLink } from "@/components/link"; import { getPublicEnvVar } from "@/lib/env"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { Typography } from "@stackframe/stack-ui"; import { loadStripe } from "@stripe/stripe-js"; -import { useCallback, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; type Props = { redirectStatus?: string, @@ -23,21 +24,32 @@ type ViewState = | { kind: "error", message: string }; const stripePublicKey = getPublicEnvVar("NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY") ?? ""; +const apiUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); +const baseUrl = new URL("/api/v1", apiUrl).toString(); export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFullCode, bypass }: Props) { const [state, setState] = useState({ kind: "loading" }); const searchParams = useSearchParams(); const returnUrl = searchParams.get("return_url"); + const checkAndReturnUser = useCallback(async () => { + if (!returnUrl || !purchaseFullCode) { + return; + } + const url = new URL(`${baseUrl}/payments/purchases/validate-code`); + url.searchParams.set("full_code", purchaseFullCode); + url.searchParams.set("return_url", returnUrl); + const response = await fetch(url); + if (response.ok) { + window.location.assign(returnUrl); + } + }, [returnUrl, purchaseFullCode]); + const updateViewState = useCallback(async (): Promise => { try { if (bypass === "1") { - if (returnUrl) { - window.location.assign(returnUrl); - } - const message = returnUrl - ? "Bypassed in test mode. No payment processed. You will be redirected shortly." - : "Bypassed in test mode. No payment processed."; + runAsynchronously(checkAndReturnUser()); + const message = `Bypassed in test mode. No payment processed.${returnUrl ? " You will be redirected shortly." : ""}`; setState({ kind: "success", message }); return; } @@ -49,12 +61,8 @@ export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFu const lastErrorMessage = result.paymentIntent?.last_payment_error?.message; if (status === "succeeded") { - if (returnUrl) { - window.location.assign(returnUrl); - } - const message = returnUrl - ? "Payment succeeded. You will be redirected shortly." - : "Payment succeeded. You can close this page."; + runAsynchronously(checkAndReturnUser()); + const message = `Payment succeeded.${returnUrl ? " You will be redirected shortly." : " You can safely close this page."}`; setState({ kind: "success", message }); return; } @@ -79,7 +87,7 @@ export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFu const message = e instanceof Error ? e.message : "Unexpected error retrieving payment."; setState({ kind: "error", message }); } - }, [clientSecret, stripeAccountId, bypass, returnUrl]); + }, [clientSecret, stripeAccountId, bypass, returnUrl, checkAndReturnUser]); useEffect(() => { runAsynchronously(updateViewState()); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts index 2cbf6b8ec2..f823c7d5f9 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts @@ -319,3 +319,52 @@ it("should allow valid product_id", async ({ expect }) => { const returnUrl = urlObj.searchParams.get("return_url"); expect(returnUrl).toBe("http://stack-test.localhost/after-purchase"); }); + +it("should error for untrusted return_url", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + 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(); + const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "client", + body: { + customer_type: "user", + customer_id: userId, + product_id: "test-product", + return_url: "https://malicious.com/callback", + }, + }); + expect(response).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", +