diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts index b104c05ecf..0d8782f113 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts @@ -1,4 +1,4 @@ -import { ensureClientCanAccessCustomer, ensureCustomerExists, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull, isActiveSubscription, isAddOnProduct } from "@/lib/payments"; +import { ensureClientCanAccessCustomer, ensureCustomerExists, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull, grantProductToCustomer, isActiveSubscription, isAddOnProduct } from "@/lib/payments"; import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; import { getApplicationFeePercentOrUndefined } from "@/lib/payments/platform-fees"; @@ -147,9 +147,11 @@ export const POST = createSmartRouteHandler({ if (!existingSub && !fromIsFreePlan) { throw new StatusError(400, "This subscription cannot be switched."); } - // Server-granted subscriptions (no stripeSubscriptionId) are immutable via this endpoint; - // they must be cancelled through admin tooling before the customer switches plans. - if (existingSub && !existingSub.stripeSubscriptionId) { + const testMode = auth.tenancy.config.payments.testMode === true; + // Server-granted subscriptions (no stripeSubscriptionId) are immutable via this endpoint + // in live mode; they must be cancelled through admin tooling before the customer switches plans. + // In test mode they're swapped via the DB-only short-circuit below. + if (existingSub != null && existingSub.stripeSubscriptionId == null && !testMode) { throw new StatusError(400, "This subscription cannot be switched."); } // Free-plan fallthrough: if the customer claims to be switching "from" a free product @@ -192,6 +194,30 @@ export const POST = createSmartRouteHandler({ throw new StatusError(400, "This product is not stackable; quantity must be 1"); } + if (testMode && (existingSub == null || existingSub.stripeSubscriptionId == null)) { + await grantProductToCustomer({ + prisma, + tenancy: auth.tenancy, + customerType: params.customer_type, + customerId: params.customer_id, + product: toProduct, + productId: body.to_product_id, + priceId: selectedPriceId, + quantity, + creationSource: "TEST_MODE", + }); + return { statusCode: 200, bodyType: "json", body: { success: true } }; + } + + // For now, we don't allow switching out of a Stripe-backed subscription while the project is in test mode. + if (testMode && existingSub != null && existingSub.stripeSubscriptionId != null) { + throw new StatusError( + 400, + "Cannot switch a live subscription while the project is in test mode. " + + "Cancel the live subscription first, or disable test mode before switching.", + ); + } + const stripe = await getStripeForAccount({ tenancy: auth.tenancy }); const stripeCustomer = await getStripeCustomerForCustomerOrNull({ stripe, 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..a07e2554ec 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 == null || data.stripeCustomerId == null) { + 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 != null, + hasStripeCustomerId: data.stripeCustomerId != null, + }, + ); + } 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-catalog-to-product-line-rename/outdated--purchase-session.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-catalog-to-product-line-rename/outdated--purchase-session.test.ts index 107b17f092..67c5ccbd36 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-catalog-to-product-line-rename/outdated--purchase-session.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-catalog-to-product-line-rename/outdated--purchase-session.test.ts @@ -120,9 +120,9 @@ it("should block one-time purchase in same group using old catalogs config", asy const codeB = (urlB.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]; expect(codeB).toBeDefined(); - const resB = await niceBackendFetch("/api/v1/payments/purchases/purchase-session", { + const resB = await niceBackendFetch("/api/v1/internal/payments/test-mode-purchase-session", { method: "POST", - accessType: "client", + accessType: "admin", body: { full_code: codeB, price_id: "one", quantity: 1 }, }); expect(resB.status).toBe(400); @@ -134,7 +134,7 @@ it("should work with subscription switching using old catalogs config", async ({ await Payments.setup(); await Project.updateConfig({ payments: { - testMode: true, + testMode: false, // Using the OLD property name "catalogs" catalogs: { grp: { displayName: "Test Group" }, 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/before-offer-to-product-rename/outdated--purchase-session.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts index c1c9e7d013..f8313f2efd 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--purchase-session.test.ts @@ -138,6 +138,7 @@ it("should return client secret for one-time price (no interval)", async ({ expe await Payments.setup(); await Project.updateConfig({ payments: { + testMode: false, products: { "ot-offer": { displayName: "One Time Offer", @@ -186,6 +187,7 @@ it("should error on one-time price quantity > 1 when offer is not stackable", as await Payments.setup(); await Project.updateConfig({ payments: { + testMode: false, products: { "ot-non-stack": { displayName: "One Time Non-Stackable", @@ -283,6 +285,9 @@ it("should return client secret for one-time price even if a conflicting group s }); expect(testModeRes.status).toBe(200); + // Flip to live mode for the next purchase. Path notation preserves products/productLines. + await Project.updateConfig({ "payments.testMode": false }); + // Now purchase one-time offer in same group; should succeed and return client secret const createUrlRespB = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", @@ -375,6 +380,7 @@ it("should create purchase URL, validate code, and create purchase session", asy it("should create purchase URL with inline offer, validate code, and create purchase session", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Payments.setup(); + await Project.updateConfig({ "payments.testMode": false }); const { userId } = await Auth.fastSignUp(); const response = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { @@ -626,7 +632,7 @@ it("should update existing stripe subscription when switching offers within a gr await Payments.setup(); await Project.updateConfig({ payments: { - testMode: true, + testMode: false, catalogs: { grp: { displayName: "Test Group" }, }, @@ -797,6 +803,9 @@ it("should cancel DB-only subscription then create Stripe subscription when swit }); expect(testModeRes.status).toBe(200); + // Flip to live mode so the next purchase exercises the Stripe path. + await Project.updateConfig({ "payments.testMode": false }); + // Now purchase offerB in non test-mode; should cancel DB-only sub and create Stripe subscription const resUrlB = await niceBackendFetch("/api/v1/payments/purchases/create-purchase-url", { method: "POST", @@ -949,9 +958,9 @@ it("should block one-time purchase in same group after prior one-time purchase i const codeB = (urlB.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1]; expect(codeB).toBeDefined(); - const resB = await niceBackendFetch("/api/v1/payments/purchases/purchase-session", { + const resB = await niceBackendFetch("/api/v1/internal/payments/test-mode-purchase-session", { method: "POST", - accessType: "client", + accessType: "admin", body: { full_code: codeB, price_id: "one", quantity: 1 }, }); expect(resB.status).toBe(400); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts index 7fa52f7868..89dd40978b 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts @@ -217,7 +217,7 @@ it("should include conflicting_group_offers when switching within the same group "status": 200, "body": { "already_bought_non_stackable": false, - "charges_enabled": false, + "charges_enabled": null, "conflicting_products": [ { "display_name": "Offer A", @@ -245,7 +245,7 @@ it("should include conflicting_group_offers when switching within the same group }, "project_id": "", "project_logo_url": null, - "stripe_account_id": , + "stripe_account_id": null, "test_mode": true, }, "headers": Headers {