Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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({
Comment on lines +197 to +198
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In test mode, cancelling the old sub relies on the TimeFold-lagged ownedProducts read instead of the existingSub we already have, so two quick switches can leave two active subs in one line — cancel existingSub directly here.

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment on lines +67 to +68
Copy link
Copy Markdown
Collaborator

@BilalG1 BilalG1 May 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should have an e2e test for this

"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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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 }) {
Expand Down Expand Up @@ -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,
},
};
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
47 changes: 30 additions & 17 deletions apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,13 +14,13 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import * as yup from "yup";
type ProductData = {
product?: Omit<yup.InferType<typeof inlineProductSchema>, "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");
Expand Down Expand Up @@ -393,22 +393,35 @@ export default function PageClient({ code }: { code: string }) {
<div className="w-full md:w-1/2 flex flex-grow items-center justify-center bg-gradient-to-br from-primary/5 via-primary/3 to-background p-6 md:p-12">
{data && (
<div className="w-full max-w-lg">
<StripeElementsProvider
stripeAccountId={data.stripe_account_id}
amount={elementsAmountCents}
mode={elementsMode}
>
<CheckoutForm
fullCode={code}
stripeAccountId={data.stripe_account_id}
setupSubscription={setupSubscription}
returnUrl={returnUrl ?? undefined}
{data.test_mode ? (
<TestModeBypassForm
onBypass={handleBypass}
disabled={quantityNumber < 1 || isTooLarge || data.already_bought_non_stackable === true}
chargesEnabled={data.charges_enabled}
onTestModeBypass={data.test_mode ? handleBypass : undefined}
isFree={isFreeSelected}
/>
</StripeElementsProvider>
) : data.stripe_account_id == null ? (
<div className="flex flex-col gap-4 max-w-md w-full p-6 rounded-md bg-background">
<Typography type="h3" variant="destructive">Payments not enabled</Typography>
<p className="text-sm text-muted-foreground">
This project does not have payments enabled yet. Please contact the app developer to finish setting up payments.
</p>
</div>
) : (
<StripeElementsProvider
stripeAccountId={data.stripe_account_id}
amount={elementsAmountCents}
mode={elementsMode}
>
<CheckoutForm
fullCode={code}
stripeAccountId={data.stripe_account_id}
setupSubscription={setupSubscription}
returnUrl={returnUrl ?? undefined}
disabled={quantityNumber < 1 || isTooLarge || data.already_bought_non_stackable === true}
chargesEnabled={data.charges_enabled ?? false}
isFree={isFreeSelected}
/>
</StripeElementsProvider>
)}
</div>
)}
</div>
Expand Down
51 changes: 26 additions & 25 deletions apps/dashboard/src/components/payments/checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,42 @@ type Props = {
fullCode: string,
returnUrl?: string,
disabled?: boolean,
onTestModeBypass?: () => Promise<void>,
chargesEnabled: boolean,
isFree: boolean,
};

export function TestModeBypassForm({
onBypass,
disabled,
}: {
onBypass: () => Promise<void>,
disabled?: boolean,
}) {
return (
<div className="flex flex-col gap-4 max-w-md w-full p-6 rounded-md bg-background">
<div className="space-y-1">
<Typography type="h3">Test mode active</Typography>
<p className="text-sm text-muted-foreground">
This project is in test mode. Use the bypass button to simulate a purchase.
</p>
</div>
<Button
disabled={disabled}
onClick={onBypass}
className="mt-2"
>
Complete test purchase
</Button>
Comment thread
nams1570 marked this conversation as resolved.
Comment thread
nams1570 marked this conversation as resolved.
</div>
);
}

export function CheckoutForm({
setupSubscription,
stripeAccountId,
fullCode,
returnUrl,
disabled,
onTestModeBypass,
chargesEnabled,
isFree,
}: Props) {
Expand Down Expand Up @@ -88,29 +112,6 @@ export function CheckoutForm({
}
};

if (onTestModeBypass) {
return (
<div className="flex flex-col gap-4 max-w-md w-full p-6 rounded-md bg-background">
<div className="space-y-1">
<Typography type="h3">Test mode active</Typography>
<p className="text-sm text-muted-foreground">
This project is in test mode. Use the bypass button to simulate a purchase.
</p>
</div>
<Button
disabled={disabled}
onClick={onTestModeBypass}
className="mt-2"
>
Complete test purchase
</Button>
{message && (
<div className="text-destructive text-sm">{message}</div>
)}
</div>
);
}

if (!chargesEnabled) {
return (
<div className="flex flex-col gap-4 max-w-md w-full p-6 rounded-md bg-background">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(`
Expand Down
Loading
Loading