From d37b7ea7c86cef0f1338f1de0c5a326e97b12f4d Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Fri, 10 Oct 2025 12:00:17 -0700 Subject: [PATCH] improve payment error messages (#931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …or non stackable offer ## 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
💡 Review Order Suggestion | 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` |
[![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](https://img.shields.io/badge/Analyze%20latest%20changes-238636?style=plastic)](https://squash-322339097191.europe-west3.run.app/interactive/b2f68f1035d0d002afffec6939f580f403c61cd11577f12f30ddb0ab490575ea/?repo_owner=stack-auth&repo_name=stack-auth&pr_number=931) ---- > [!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. > > This description was created by [Ellipsis](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral) for 5495b9c269deb9d62bdfd16440ecdedf754ee07f. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed. ---- ## 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). --------- Co-authored-by: Konsti Wohlwend --- .../purchases/create-purchase-url/route.ts | 20 ++++- apps/backend/src/lib/payments.tsx | 54 ++++++++---- .../outdated--create-purchase-url.test.ts | 44 ++++++---- .../outdated--purchase-session.test.ts | 18 ++-- .../outdated--validate-code.test.ts | 35 +------- .../v1/payments/create-purchase-url.test.ts | 85 ++++++++++++++++++- .../api/v1/payments/purchase-session.test.ts | 18 ++-- .../api/v1/payments/validate-code.test.ts | 35 +------- packages/stack-shared/src/known-errors.tsx | 15 ++-- 9 files changed, 191 insertions(+), 133 deletions(-) diff --git a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts index c09a0708f..6050eaedf 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts @@ -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}'`, }); diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 1fe63ff87..7e8bbe729 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -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) : []; diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts index 5c0f94e41..7797f72ff 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--create-purchase-url.test.ts @@ -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", -