fix error messages for using item id as offer id, creating checkout for non stackable offer

This commit is contained in:
Bilal Godil 2025-10-07 12:18:38 -07:00
parent 017b43fe9b
commit 5fa068e77b
5 changed files with 171 additions and 47 deletions

View File

@ -1,13 +1,13 @@
import { ensureProductIdOrInlineProduct } from "@/lib/payments";
import { ensureProductIdOrInlineProduct, getCustomerPurchaseContext } from "@/lib/payments";
import { validateRedirectUrl } from "@/lib/redirect-urls";
import { getStripeForAccount } from "@/lib/stripe";
import { globalPrismaClient } from "@/prisma-client";
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { CustomerType } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
import { adaptSchema, clientOrHigherAuthTypeSchema, inlineProductSchema, urlSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler";
export const POST = createSmartRouteHandler({
@ -44,6 +44,20 @@ export const POST = createSmartRouteHandler({
throw new KnownErrors.ProductCustomerTypeDoesNotMatch(req.body.product_id, req.body.customer_id, customerType, req.body.customer_type);
}
if (req.body.product_id && productConfig.stackable !== true) {
const prisma = await getPrismaClientForTenancy(tenancy);
const { alreadyOwnsProduct } = await getCustomerPurchaseContext({
prisma,
tenancy,
customerType,
customerId: req.body.customer_id,
productId: req.body.product_id,
});
if (alreadyOwnsProduct) {
throw new StatusError(400, "Customer already has purchased this product; this product is not stackable");
}
}
const stripeCustomerSearch = await stripe.customers.search({
query: `metadata['customerId']:'${req.body.customer_id}'`,
});

View File

@ -5,7 +5,7 @@ import type { inlineProductSchema, productSchema } from "@stackframe/stack-share
import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants";
import { FAR_FUTURE_DATE, addInterval, getIntervalsElapsed } from "@stackframe/stack-shared/dist/utils/dates";
import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { getOrUndefined, typedEntries, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
import { getOrUndefined, has, typedEntries, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import Stripe from "stripe";
@ -32,10 +32,11 @@ export async function ensureProductIdOrInlineProduct(
if (productId) {
const product = getOrUndefined(tenancy.config.payments.products, productId);
if (!product) {
throw new KnownErrors.ProductDoesNotExist(productId, accessType);
const itemExists = has(tenancy.config.payments.items, productId);
throw new KnownErrors.ProductDoesNotExist(productId, itemExists ? "item_exists" : null);
}
if (product.serverOnly && accessType === "client") {
throw new StatusError(400, "This product is marked as server-only and cannot be accessed client side!");
throw new KnownErrors.ProductDoesNotExist(productId, "server_only");
}
return product;
} else {
@ -347,6 +348,35 @@ export async function getSubscriptions(options: {
return subscriptions;
}
export async function getCustomerPurchaseContext(options: {
prisma: PrismaClientTransaction,
tenancy: Tenancy,
customerType: "user" | "team" | "custom",
customerId: string,
productId?: string,
}) {
const existingOneTimePurchases = await options.prisma.oneTimePurchase.findMany({
where: {
tenancyId: options.tenancy.id,
customerId: options.customerId,
customerType: typedToUppercase(options.customerType),
},
});
const subscriptions = await getSubscriptions({
prisma: options.prisma,
tenancy: options.tenancy,
customerType: options.customerType,
customerId: options.customerId,
});
const alreadyOwnsProduct = options.productId
? [...subscriptions, ...existingOneTimePurchases].some((p) => p.productId === options.productId)
: false;
return { existingOneTimePurchases, subscriptions, alreadyOwnsProduct };
}
export async function ensureCustomerExists(options: {
prisma: PrismaClientTransaction,
tenancyId: string,
@ -427,27 +457,15 @@ export async function validatePurchaseSession(options: {
throw new StatusError(400, "This product is not stackable; quantity must be 1");
}
// Block based on prior one-time purchases for same customer and customerType
const existingOneTimePurchases = await prisma.oneTimePurchase.findMany({
where: {
tenancyId: tenancy.id,
customerId: codeData.customerId,
customerType: typedToUppercase(product.customerType),
},
});
const subscriptions = await getSubscriptions({
const { existingOneTimePurchases, subscriptions, alreadyOwnsProduct } = await getCustomerPurchaseContext({
prisma,
tenancy,
customerType: product.customerType,
customerId: codeData.customerId,
productId: codeData.productId,
});
if (
codeData.productId &&
product.stackable !== true &&
[...subscriptions, ...existingOneTimePurchases].some((p) => p.productId === codeData.productId)
) {
if (product.stackable !== true && alreadyOwnsProduct) {
throw new StatusError(400, "Customer already has purchased this product; this product is not stackable");
}
const addOnProductIds = product.isAddOnTo ? typedKeys(product.isAddOnTo) : [];

View File

@ -55,22 +55,22 @@ it("should error for non-existent offer_id", async ({ expect }) => {
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "PRODUCT_DOES_NOT_EXIST",
"details": {
"access_type": "client",
"product_id": "non-existent-offer",
},
"error": "Product with ID \\"non-existent-offer\\" does not exist or you don't have permissions to access it.",
NiceResponse {
"status": 400,
"body": {
"code": "PRODUCT_DOES_NOT_EXIST",
"details": {
"context": null,
"product_id": "non-existent-offer",
},
"headers": Headers {
"x-stack-known-error": "PRODUCT_DOES_NOT_EXIST",
<some fields may have been hidden>,
},
}
`);
"error": "Product with ID \\"non-existent-offer\\" does not exist.",
},
"headers": Headers {
"x-stack-known-error": "PRODUCT_DOES_NOT_EXIST",
<some fields may have been hidden>,
},
}
`);
});
it("should error for invalid customer_id", async ({ expect }) => {
@ -233,8 +233,18 @@ it("should error for server-only offer when calling from client", async ({ expec
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "This product is marked as server-only and cannot be accessed client side!",
"headers": Headers { <some fields may have been hidden> },
"body": {
"code": "PRODUCT_DOES_NOT_EXIST",
"details": {
"context": "server_only",
"product_id": "test-offer",
},
"error": "Product with ID \\"test-offer\\" is marked as server-only and cannot be accessed client side.",
},
"headers": Headers {
"x-stack-known-error": "PRODUCT_DOES_NOT_EXIST",
<some fields may have been hidden>,
},
}
`);
});

View File

@ -60,10 +60,10 @@ it("should error for non-existent product_id", async ({ expect }) => {
"body": {
"code": "PRODUCT_DOES_NOT_EXIST",
"details": {
"access_type": "client",
"context": null,
"product_id": "non-existent-product",
},
"error": "Product with ID \\"non-existent-product\\" does not exist or you don't have permissions to access it.",
"error": "Product with ID \\"non-existent-product\\" does not exist.",
},
"headers": Headers {
"x-stack-known-error": "PRODUCT_DOES_NOT_EXIST",
@ -233,8 +233,18 @@ it("should error for server-only product when calling from client", async ({ exp
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "This product is marked as server-only and cannot be accessed client side!",
"headers": Headers { <some fields may have been hidden> },
"body": {
"code": "PRODUCT_DOES_NOT_EXIST",
"details": {
"context": "server_only",
"product_id": "test-product",
},
"error": "Product with ID \\"test-product\\" is marked as server-only and cannot be accessed client side.",
},
"headers": Headers {
"x-stack-known-error": "PRODUCT_DOES_NOT_EXIST",
<some fields may have been hidden>,
},
}
`);
});
@ -310,6 +320,73 @@ it("should allow valid product_id", async ({ expect }) => {
expect(returnUrl).toBe("http://stack-test.localhost/after-purchase");
});
it("should error when customer already owns a non-stackable product", 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: {},
},
},
},
});
const { userId } = await User.create();
const firstResponse = 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(firstResponse.status).toBe(200);
const firstBody = firstResponse.body as { url: string };
const firstUrl = new URL(firstBody.url);
const fullCode = firstUrl.pathname.split("/").pop();
expect(fullCode).toBeDefined();
if (!fullCode) {
throw new Error("Expected full purchase code");
}
const purchaseResponse = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
method: "POST",
accessType: "admin",
body: {
full_code: fullCode,
price_id: "monthly",
quantity: 1,
},
});
expect(purchaseResponse.status).toBe(200);
const secondResponse = 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(secondResponse.status).toBe(400);
expect(secondResponse.body).toBe("Customer already has purchased this product; this product is not stackable");
});
it("should error for untrusted return_url", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();

View File

@ -1495,15 +1495,20 @@ const CustomerDoesNotExist = createKnownErrorConstructor(
const ProductDoesNotExist = createKnownErrorConstructor(
KnownError,
"PRODUCT_DOES_NOT_EXIST",
(productId: string, accessType: "client" | "server" | "admin") => [
(productId: string, context: "item_exists" | "server_only" | null) => [
400,
`Product with ID ${JSON.stringify(productId)} does not exist${accessType === "client" ? " or you don't have permissions to access it." : "."}`,
`Product with ID ${JSON.stringify(productId)} ${context === "server_only"
? "is marked as server-only and cannot be accessed client side."
: context === "item_exists"
? "does not exist, but an item with this ID exists."
: "does not exist."
}`,
{
product_id: productId,
access_type: accessType,
},
context,
} as const,
] as const,
(json) => [json.product_id, json.access_type] as const,
(json) => [json.product_id, json.context] as const,
);
const ProductCustomerTypeDoesNotMatch = createKnownErrorConstructor(