mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
config: payments.blockNewPurchases
This commit is contained in:
parent
ba38f26014
commit
0d38f07caf
@ -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");
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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-_]+/);
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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>>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user