config: payments.blockNewPurchases

This commit is contained in:
Konstantin Wohlwend 2026-01-14 14:41:10 -08:00
parent ba38f26014
commit 0d38f07caf
8 changed files with 417 additions and 0 deletions

View File

@ -4,6 +4,7 @@ import { getTenancy } from "@/lib/tenancies";
import { getStripeForAccount } from "@/lib/stripe";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
@ -30,6 +31,9 @@ export const POST = createSmartRouteHandler({
if (!tenancy) {
throw new StackAssertionError("Tenancy not found for test mode purchase session");
}
if (tenancy.config.payments.blockNewPurchases) {
throw new KnownErrors.NewPurchasesBlocked();
}
if (tenancy.config.payments.testMode !== true) {
throw new StatusError(403, "Test mode is not enabled for this project");
}

View File

@ -42,6 +42,9 @@ export const POST = createSmartRouteHandler({
}).defined(),
}),
handler: async ({ auth, params, body }, fullReq) => {
if (auth.tenancy.config.payments.blockNewPurchases) {
throw new KnownErrors.NewPurchasesBlocked();
}
if (auth.type === "client") {
await ensureClientCanAccessCustomer({
customerType: params.customer_type,

View File

@ -68,6 +68,9 @@ export const POST = createSmartRouteHandler({
}),
handler: async (req) => {
const { tenancy } = req.auth;
if (tenancy.config.payments.blockNewPurchases) {
throw new KnownErrors.NewPurchasesBlocked();
}
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;

View File

@ -4,6 +4,7 @@ import { getTenancy } from "@/lib/tenancies";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { SubscriptionStatus } from "@/generated/prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler";
@ -56,6 +57,9 @@ export const POST = createSmartRouteHandler({
if (!tenancy) {
throw new StackAssertionError("No tenancy found from purchase code data tenancy id. This should never happen.");
}
if (tenancy.config.payments.blockNewPurchases) {
throw new KnownErrors.NewPurchasesBlocked();
}
const stripe = await getStripeForAccount({ accountId: data.stripeAccountId });
const prisma = await getPrismaClientForTenancy(tenancy);
const { selectedPrice, conflictingCatalogSubscriptions } = await validatePurchaseSession({

View File

@ -0,0 +1,389 @@
import { it } from "../../../../../helpers";
import { Auth, Payments, Project, User, niceBackendFetch } from "../../../../backend-helpers";
it("should block create-purchase-url when blockNewPurchases is enabled", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();
await Project.updateConfig({
payments: {
blockNewPurchases: true,
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",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 403,
"body": {
"code": "NEW_PURCHASES_BLOCKED",
"error": "New purchases are currently blocked for this project. Please contact support for more information.",
},
"headers": Headers {
"x-stack-known-error": "NEW_PURCHASES_BLOCKED",
<some fields may have been hidden>,
},
}
`);
});
it("should block purchase-session when blockNewPurchases is enabled", 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: {},
},
},
},
});
// Create purchase URL before enabling blockNewPurchases
const { userId } = await User.create();
const createUrlResponse = 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(createUrlResponse.status).toBe(200);
const url = (createUrlResponse.body as { url: string }).url;
const code = url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(code).toBeDefined();
// Now enable blockNewPurchases
await Project.updateConfig({
payments: {
blockNewPurchases: true,
},
});
// Try to use the purchase session
const response = await niceBackendFetch("/api/latest/payments/purchases/purchase-session", {
method: "POST",
accessType: "client",
body: {
full_code: code,
price_id: "monthly",
quantity: 1,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 403,
"body": {
"code": "NEW_PURCHASES_BLOCKED",
"error": "New purchases are currently blocked for this project. Please contact support for more information.",
},
"headers": Headers {
"x-stack-known-error": "NEW_PURCHASES_BLOCKED",
<some fields may have been hidden>,
},
}
`);
});
it("should block test-mode-purchase-session when blockNewPurchases is enabled", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();
await Project.updateConfig({
payments: {
testMode: true,
products: {
"test-product": {
displayName: "Test Product",
customerType: "user",
serverOnly: false,
stackable: false,
prices: {
"monthly": {
USD: "1000",
interval: [1, "month"],
},
},
includedItems: {},
},
},
},
});
// Create purchase URL before enabling blockNewPurchases
const { userId } = await User.create();
const createUrlResponse = 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(createUrlResponse.status).toBe(200);
const url = (createUrlResponse.body as { url: string }).url;
const code = url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(code).toBeDefined();
// Now enable blockNewPurchases
await Project.updateConfig({
payments: {
blockNewPurchases: true,
},
});
// Try to use the test-mode purchase session
const response = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
method: "POST",
accessType: "admin",
body: {
full_code: code,
price_id: "monthly",
quantity: 1,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 403,
"body": {
"code": "NEW_PURCHASES_BLOCKED",
"error": "New purchases are currently blocked for this project. Please contact support for more information.",
},
"headers": Headers {
"x-stack-known-error": "NEW_PURCHASES_BLOCKED",
<some fields may have been hidden>,
},
}
`);
});
it("should block switch endpoint when blockNewPurchases is enabled", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
await Project.updateConfig({
payments: {
blockNewPurchases: true,
catalogs: {
catalog: { displayName: "Plans" },
},
products: {
planA: {
displayName: "Plan A",
customerType: "user",
serverOnly: false,
stackable: false,
catalogId: "catalog",
prices: "include-by-default",
includedItems: {},
},
planB: {
displayName: "Plan B",
customerType: "user",
serverOnly: false,
stackable: false,
catalogId: "catalog",
prices: {
monthly: {
USD: "2000",
interval: [1, "month"],
},
},
includedItems: {},
},
},
},
});
const { userId } = await Auth.fastSignUp();
const switchResponse = await niceBackendFetch(`/api/latest/payments/products/user/${userId}/switch`, {
method: "POST",
accessType: "client",
body: {
from_product_id: "planA",
to_product_id: "planB",
},
});
expect(switchResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 403,
"body": {
"code": "NEW_PURCHASES_BLOCKED",
"error": "New purchases are currently blocked for this project. Please contact support for more information.",
},
"headers": Headers {
"x-stack-known-error": "NEW_PURCHASES_BLOCKED",
<some fields may have been hidden>,
},
}
`);
});
it("should allow purchases when blockNewPurchases is false (default)", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();
await Project.updateConfig({
payments: {
blockNewPurchases: false,
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",
},
});
expect(response.status).toBe(200);
expect((response.body as { url: string }).url).toMatch(/\/purchase\/[a-z0-9-_]+/);
});
it("should allow purchases when blockNewPurchases is not set (defaults to false)", 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",
},
});
expect(response.status).toBe(200);
expect((response.body as { url: string }).url).toMatch(/\/purchase\/[a-z0-9-_]+/);
});
it("should allow disabling blockNewPurchases to resume purchases", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();
await Project.updateConfig({
payments: {
blockNewPurchases: true,
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();
// First, verify purchases are blocked
const blockedResponse = 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(blockedResponse.status).toBe(403);
expect((blockedResponse.body as { code: string }).code).toBe("NEW_PURCHASES_BLOCKED");
// Now disable blockNewPurchases (keeping the products config)
await Project.updateConfig({
"payments.blockNewPurchases": false,
});
// Verify purchases work again (same user since they never actually purchased)
const allowedResponse = 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(allowedResponse.status).toBe(200);
expect((allowedResponse.body as { url: string }).url).toMatch(/\/purchase\/[a-z0-9-_]+/);
});

View File

@ -60,6 +60,7 @@ const branchSchemaFuzzerConfig = [{
}],
}],
payments: [{
blockNewPurchases: [false, true],
testMode: [false, true],
autoPay: [{
interval: [[[0, 1, -3, 100, 0.333, Infinity], ["day", "week", "month", "year"]]] as const,

View File

@ -144,6 +144,7 @@ const branchAuthSchema = yupObject({
});
export const branchPaymentsSchema = yupObject({
blockNewPurchases: yupBoolean(),
autoPay: yupObject({
interval: schemaFields.dayIntervalSchema,
}).optional(),
@ -545,6 +546,7 @@ const organizationConfigDefaults = {
},
payments: {
blockNewPurchases: false,
testMode: true,
autoPay: undefined,
catalogs: (key: string) => ({

View File

@ -1686,6 +1686,16 @@ const DefaultPaymentMethodRequired = createKnownErrorConstructor(
(json) => [json.customer_type, json.customer_id] as const,
);
const NewPurchasesBlocked = createKnownErrorConstructor(
KnownError,
"NEW_PURCHASES_BLOCKED",
() => [
403,
"New purchases are currently blocked for this project. Please contact support for more information.",
] as const,
() => [] as const,
);
export type KnownErrors = {
[K in keyof typeof KnownErrors]: InstanceType<typeof KnownErrors[K]>;
};
@ -1818,6 +1828,7 @@ export const KnownErrors = {
ItemQuantityInsufficientAmount,
StripeAccountInfoNotFound,
DefaultPaymentMethodRequired,
NewPurchasesBlocked,
DataVaultStoreDoesNotExist,
DataVaultStoreHashedKeyDoesNotExist,
} satisfies Record<string, KnownErrorConstructor<any, any>>;