improve payment error messages (#931)

…or non stackable offer

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->

<!-- RECURSEML_SUMMARY:START -->
## High-level PR Summary
This PR improves error messaging in the payments system by addressing
two main issues: (1) providing clearer error messages when an item ID is
mistakenly used as a product ID, including distinguishing between
non-existent products, server-only products, and cases where an item
exists with that ID, and (2) preventing checkout creation for
non-stackable products that a customer already owns by adding an early
validation check. The changes update the `ProductDoesNotExist` known
error to include contextual information, introduce a new
`getCustomerPurchaseContext` helper function to check existing
purchases, and update related test snapshots to reflect the improved
error messages.

⏱️ Estimated Review Time: 15-30 minutes

<details>
<summary>💡 Review Order Suggestion</summary>

| Order | File Path |
|-------|-----------|
| 1 | `packages/stack-shared/src/known-errors.tsx` |
| 2 | `apps/backend/src/lib/payments.tsx` |
| 3 |
`apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts`
|
| 4 |
`apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts`
|
| 5 |
`apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts`
|
</details>



[![Need help? Join our
Discord](https://img.shields.io/badge/Need%20help%3F%20Join%20our%20Discord-5865F2?style=plastic&logo=discord&logoColor=white)](https://discord.gg/n3SsVDAW6U)


[![Analyze latest
changes](b2f68f1035/?repo_owner=stack-auth&repo_name=stack-auth&pr_number=931)
<!-- RECURSEML_SUMMARY:END -->
<!-- ELLIPSIS_HIDDEN -->


----

> [!IMPORTANT]
> Enhances payment error handling by adding context to error messages
and preventing duplicate purchases of non-stackable products.
> 
>   - **Behavior**:
> - Adds early validation in `create-purchase-url/route.ts` to prevent
checkout for non-stackable products already owned by the customer.
> - Updates `ensureProductIdOrInlineProduct` in `payments.tsx` to
include context in `ProductDoesNotExist` error.
> - Introduces `getCustomerPurchaseContext` in `payments.tsx` to check
existing purchases.
>   - **Errors**:
> - Modifies `ProductDoesNotExist` in `known-errors.tsx` to include
`context` (null, server_only, item_exists).
>   - **Tests**:
> - Updates test cases in `create-purchase-url.test.ts`,
`purchase-session.test.ts`, and `validate-code.test.ts` to reflect new
error messages and behavior.
> - Adds tests for blocking repeat purchases of non-stackable products.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for 5495b9c269. You can
[customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this
summary. It will automatically update as commits are pushed.</sup>

----


<!-- ELLIPSIS_HIDDEN -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- New Features
- Blocks creating a purchase URL when a customer already owns a
non-stackable product, preventing duplicate checkouts.

- Bug Fixes
- Standardized error responses for missing or server-only products with
consistent codes, messages, and structured details.
- Error payloads now include details.context (null, server_only, or
item_exists) instead of access_type.

- Tests
- Added and updated tests to cover blocking repeat purchases and the new
standardized error format (including inline snapshot updates).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
BilalG1 2025-10-10 12:00:17 -07:00 committed by GitHub
parent 3e301b025a
commit d37b7ea7c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 191 additions and 133 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

@ -793,17 +793,13 @@ it("should block one-time purchase for same product after prior one-time purchas
accessType: "client",
body: { customer_type: "user", customer_id: userId, offer_id: "ot" },
});
expect(createUrl2.status).toBe(200);
const code2 = (createUrl2.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(code2).toBeDefined();
const res = await niceBackendFetch("/api/v1/payments/purchases/purchase-session", {
method: "POST",
accessType: "client",
body: { full_code: code2, price_id: "one", quantity: 1 },
});
expect(res.status).toBe(400);
expect(String(res.body)).toBe("Customer already has purchased this product; this product is not stackable");
expect(createUrl2).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "Customer already has purchased this product; this product is not stackable",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should block one-time purchase in same group after prior one-time purchase in that group (test-mode persisted)", async ({ expect }) => {

View File

@ -122,39 +122,10 @@ it("should set already_bought_non_stackable when user already owns non-stackable
offer_id: "test-offer",
},
});
expect(createUrlRes2.status).toBe(200);
const code2 = (createUrlRes2.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(code2).toBeDefined();
const validateResponse = await niceBackendFetch("/api/v1/payments/purchases/validate-code", {
method: "POST",
accessType: "client",
body: { full_code: code2 },
});
expect(validateResponse).toMatchInlineSnapshot(`
expect(createUrlRes2).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"already_bought_non_stackable": true,
"conflicting_products": [],
"product": {
"customer_type": "user",
"display_name": "Test Offer",
"prices": {
"monthly": {
"USD": "1000",
"interval": [
1,
"month",
],
},
},
"stackable": false,
},
"project_id": "<stripped UUID>",
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": true,
},
"status": 400,
"body": "Customer already has purchased this product; this product is not stackable",
"headers": Headers { <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

@ -809,17 +809,13 @@ it("should block one-time purchase for same product after prior one-time purchas
accessType: "client",
body: { customer_type: "user", customer_id: userId, product_id: "ot" },
});
expect(createUrl2.status).toBe(200);
const code2 = (createUrl2.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(code2).toBeDefined();
const res = await niceBackendFetch("/api/latest/payments/purchases/purchase-session", {
method: "POST",
accessType: "client",
body: { full_code: code2, price_id: "one", quantity: 1 },
});
expect(res.status).toBe(400);
expect(String(res.body)).toBe("Customer already has purchased this product; this product is not stackable");
expect(createUrl2).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "Customer already has purchased this product; this product is not stackable",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should block one-time purchase in same group after prior one-time purchase in that group (test-mode persisted)", async ({ expect }) => {

View File

@ -122,39 +122,10 @@ it("should set already_bought_non_stackable when user already owns non-stackable
product_id: "test-product",
},
});
expect(createUrlRes2.status).toBe(200);
const code2 = (createUrlRes2.body as { url: string }).url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(code2).toBeDefined();
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
method: "POST",
accessType: "client",
body: { full_code: code2 },
});
expect(validateResponse).toMatchInlineSnapshot(`
expect(createUrlRes2).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"already_bought_non_stackable": true,
"conflicting_products": [],
"product": {
"customer_type": "user",
"display_name": "Test Product",
"prices": {
"monthly": {
"USD": "1000",
"interval": [
1,
"month",
],
},
},
"stackable": false,
},
"project_id": "<stripped UUID>",
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": true,
},
"status": 400,
"body": "Customer already has purchased this product; this product is not stackable",
"headers": Headers { <some fields may have been hidden> },
}
`);

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(