mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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> [](https://discord.gg/n3SsVDAW6U) [ <!-- 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> for5495b9c269. 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:
parent
3e301b025a
commit
d37b7ea7c8
@ -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}'`,
|
||||
});
|
||||
|
||||
@ -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) : [];
|
||||
|
||||
@ -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>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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> },
|
||||
}
|
||||
`);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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> },
|
||||
}
|
||||
`);
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user