From 5b9e70e04be8adb289db5ae59eeb7e50cd6b1eef Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 27 May 2026 17:26:45 -0700 Subject: [PATCH 1/6] refactor/fix: checkout urls work w/o onboarding being complete --- .../purchases/create-purchase-url/route.ts | 50 ++++++++++-------- .../purchases/purchase-session/route.tsx | 13 +++++ .../payments/purchases/validate-code/route.ts | 8 +-- .../purchases/verification-code-handler.tsx | 6 +-- .../(main)/purchase/[code]/page-client.tsx | 47 ++++++++++------- .../src/components/payments/checkout.tsx | 51 ++++++++++--------- .../outdated--create-purchase-url.test.ts | 5 +- .../v1/payments/create-purchase-url.test.ts | 44 ++++++++++++++-- docs-mintlify/openapi/admin.json | 4 +- docs-mintlify/openapi/client.json | 4 +- docs-mintlify/openapi/server.json | 4 +- 11 files changed, 153 insertions(+), 83 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 7f6f8175f1..9a0fbe53ad 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 @@ -83,7 +83,6 @@ export const POST = createSmartRouteHandler({ }); } - const stripe = await getStripeForAccount({ tenancy }); const productConfig = await ensureProductIdOrInlineProduct(tenancy, req.auth.type, req.body.product_id, req.body.product_inline); const customerType = productConfig.customerType; if (req.body.customer_type !== customerType) { @@ -103,26 +102,37 @@ export const POST = createSmartRouteHandler({ } } - const stripeCustomerSearch = await stripe.customers.search({ - query: `metadata['customerId']:'${req.body.customer_id}'`, - }); - let stripeCustomer = stripeCustomerSearch.data.length ? stripeCustomerSearch.data[0] : undefined; - if (!stripeCustomer) { - stripeCustomer = await stripe.customers.create({ - metadata: { - customerId: req.body.customer_id, - customerType: customerType === "user" ? CustomerType.USER : CustomerType.TEAM, - } + const testMode = tenancy.config.payments.testMode === true; + let stripeCustomerId: string | undefined; + let stripeAccountId: string | undefined; + let chargesEnabled: boolean | undefined; + + if (!testMode) { + const stripe = await getStripeForAccount({ tenancy }); + const stripeCustomerSearch = await stripe.customers.search({ + query: `metadata['customerId']:'${req.body.customer_id}'`, }); + let stripeCustomer = stripeCustomerSearch.data.length ? stripeCustomerSearch.data[0] : undefined; + if (!stripeCustomer) { + stripeCustomer = await stripe.customers.create({ + metadata: { + customerId: req.body.customer_id, + customerType: customerType === "user" ? CustomerType.USER : CustomerType.TEAM, + } + }); + } + + const project = await globalPrismaClient.project.findUnique({ + where: { id: tenancy.project.id }, + select: { stripeAccountId: true }, + }); + stripeAccountId = project?.stripeAccountId ?? throwErr("Stripe account not configured"); + const stackStripe = getStackStripe(); + const connectedAccount = await stackStripe.accounts.retrieve(stripeAccountId); + stripeCustomerId = stripeCustomer.id; + chargesEnabled = connectedAccount.charges_enabled; } - const project = await globalPrismaClient.project.findUnique({ - where: { id: tenancy.project.id }, - select: { stripeAccountId: true }, - }); - const stripeAccountId = project?.stripeAccountId ?? throwErr("Stripe account not configured"); - const stackStripe = getStackStripe(); - const connectedAccount = await stackStripe.accounts.retrieve(stripeAccountId); const { code } = await purchaseUrlVerificationCodeHandler.createCode({ tenancy, expiresInMs: 1000 * 60 * 60 * 24, @@ -131,9 +141,9 @@ export const POST = createSmartRouteHandler({ customerId: req.body.customer_id, productId: req.body.product_id, product: productConfig, - stripeCustomerId: stripeCustomer.id, + stripeCustomerId, stripeAccountId, - chargesEnabled: connectedAccount.charges_enabled, + chargesEnabled, }, method: {}, callbackUrl: undefined, diff --git a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx index 478da7b6e0..17f26df9ac 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx @@ -64,6 +64,19 @@ export const POST = createSmartRouteHandler({ if (tenancy.config.payments.blockNewPurchases) { throw new KnownErrors.NewPurchasesBlocked(); } + if (!data.stripeAccountId || !data.stripeCustomerId) { + throw new HexclaveAssertionError( + "Live purchase-session called with a purchase code that has no Stripe identifiers. " + + "Test-mode codes should be routed to /internal/payments/test-mode-purchase-session instead.", + { + tenancyId: tenancy.id, + testMode: tenancy.config.payments.testMode === true, + customerId: data.customerId, + hasStripeAccountId: !!data.stripeAccountId, + hasStripeCustomerId: !!data.stripeCustomerId, + }, + ); + } const stripe = await getStripeForAccount({ accountId: data.stripeAccountId }); const prisma = await getPrismaClientForTenancy(tenancy); const { selectedPrice, conflictingSubscriptions } = await validatePurchaseSession({ 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 3c8c4825ce..b6c7ed14bc 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 @@ -37,7 +37,7 @@ export const POST = createSmartRouteHandler({ bodyType: yupString().oneOf(["json"]).defined(), body: yupObject({ product: inlineProductSchema, - stripe_account_id: yupString().defined(), + stripe_account_id: yupString().nullable().defined(), project_id: yupString().defined(), project_logo_url: yupString().nullable().defined(), already_bought_non_stackable: yupBoolean().defined(), @@ -46,7 +46,7 @@ export const POST = createSmartRouteHandler({ display_name: yupString().defined(), }).defined()).defined(), test_mode: yupBoolean().defined(), - charges_enabled: yupBoolean().defined(), + charges_enabled: yupBoolean().nullable().defined(), }).defined(), }), async handler({ body }) { @@ -98,13 +98,13 @@ export const POST = createSmartRouteHandler({ bodyType: "json", body: { product: productToInlineProduct(product), - stripe_account_id: verificationCode.data.stripeAccountId, + stripe_account_id: verificationCode.data.stripeAccountId ?? null, project_id: tenancy.project.id, project_logo_url: tenancy.project.logo_url ?? null, already_bought_non_stackable: alreadyBoughtNonStackable, conflicting_products: conflictingProductLineProducts, test_mode: tenancy.config.payments.testMode === true, - charges_enabled: verificationCode.data.chargesEnabled, + charges_enabled: verificationCode.data.chargesEnabled ?? null, }, }; }, diff --git a/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx b/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx index b6094ab563..13e72d0450 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx @@ -10,9 +10,9 @@ export const purchaseUrlVerificationCodeHandler = createVerificationCodeHandler( customerId: yupString().defined(), productId: yupString(), product: productSchema, - stripeCustomerId: yupString().defined(), - stripeAccountId: yupString().defined(), - chargesEnabled: yupBoolean().defined(), + stripeCustomerId: yupString().optional(), + stripeAccountId: yupString().optional(), + chargesEnabled: yupBoolean().optional(), }), // @ts-ignore TODO: fix this async handler(_, __, data) { 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 e401c9cd35..9938e95aa7 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { CheckoutForm } from "@/components/payments/checkout"; +import { CheckoutForm, TestModeBypassForm } from "@/components/payments/checkout"; import { StripeElementsProvider } from "@/components/payments/stripe-elements-provider"; import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, Input, Skeleton, Typography } from "@/components/ui"; import { getPublicEnvVar } from "@/lib/env"; @@ -14,13 +14,13 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; type ProductData = { product?: Omit, "included_items" | "server_only"> & { stackable: boolean }, - stripe_account_id: string, + stripe_account_id: string | null, project_id: string, project_logo_url: string | null, already_bought_non_stackable?: boolean, conflicting_products?: { product_id: string, display_name: string }[], test_mode: boolean, - charges_enabled: boolean, + charges_enabled: boolean | null, }; const apiUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); @@ -393,22 +393,35 @@ export default function PageClient({ code }: { code: string }) {
{data && (
- - - + ) : data.stripe_account_id == null ? ( +
+ Payments not enabled +

+ This project does not have payments enabled yet. Please contact the app developer to finish setting up payments. +

+
+ ) : ( + + + + )}
)}
diff --git a/apps/dashboard/src/components/payments/checkout.tsx b/apps/dashboard/src/components/payments/checkout.tsx index 175fae7f82..595c800183 100644 --- a/apps/dashboard/src/components/payments/checkout.tsx +++ b/apps/dashboard/src/components/payments/checkout.tsx @@ -23,18 +23,42 @@ type Props = { fullCode: string, returnUrl?: string, disabled?: boolean, - onTestModeBypass?: () => Promise, chargesEnabled: boolean, isFree: boolean, }; +export function TestModeBypassForm({ + onBypass, + disabled, +}: { + onBypass: () => Promise, + disabled?: boolean, +}) { + return ( +
+
+ Test mode active +

+ This project is in test mode. Use the bypass button to simulate a purchase. +

+
+ +
+ ); +} + export function CheckoutForm({ setupSubscription, stripeAccountId, fullCode, returnUrl, disabled, - onTestModeBypass, chargesEnabled, isFree, }: Props) { @@ -88,29 +112,6 @@ export function CheckoutForm({ } }; - if (onTestModeBypass) { - return ( -
-
- Test mode active -

- This project is in test mode. Use the bypass button to simulate a purchase. -

-
- - {message && ( -
{message}
- )} -
- ); - } - if (!chargesEnabled) { return (
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts index 56b75970f9..9322548f00 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts @@ -129,10 +129,11 @@ it("should error for invalid customer_id", async ({ expect }) => { `); }); -it("should error for no connected stripe account", async ({ expect }) => { +it("should error for no connected stripe account in live mode", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Project.updateConfig({ payments: { + testMode: false, products: { "test-offer": { displayName: "Test Offer", @@ -158,7 +159,7 @@ it("should error for no connected stripe account", async ({ expect }) => { body: { customer_type: "user", customer_id: userId, - product_id: "test-product", + product_id: "test-offer", }, }); expect(response).toMatchInlineSnapshot(` 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 177cc6505b..3efff90a1d 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 @@ -129,10 +129,11 @@ it("should error for invalid customer_id", async ({ expect }) => { `); }); -it("should error for no connected stripe account", async ({ expect }) => { +it("should error for no connected stripe account in live mode", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Project.updateConfig({ payments: { + testMode: false, products: { "test-product": { displayName: "Test Product", @@ -170,6 +171,43 @@ it("should error for no connected stripe account", async ({ expect }) => { `); }); +it("should create purchase URL in test mode without Stripe onboarding", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + 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 Auth.fastSignUp(); + 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", + }, + }); + expect(response.status).toBe(200); + const body = response.body as { url: string }; + expect(body.url).toMatch(new RegExp(`^https?:\/\/localhost:${withPortPrefix("01")}\/purchase\/[a-z0-9-_]+$`)); +}); + it("should not allow product_inline when calling from client", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); @@ -327,7 +365,7 @@ it("should return inline product metadata when validating purchase code", async "status": 200, "body": { "already_bought_non_stackable": false, - "charges_enabled": false, + "charges_enabled": null, "conflicting_products": [], "product": { "client_metadata": null, @@ -356,7 +394,7 @@ it("should return inline product metadata when validating purchase code", async }, "project_id": "", "project_logo_url": null, - "stripe_account_id": , + "stripe_account_id": null, "test_mode": true, }, "headers": Headers {