mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
inline product metadata (#963)
<!-- 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 adds support for custom `metadata` to inline products in the payments system. The change allows developers to attach arbitrary metadata to products created inline (without pre-configuration), which Stack Auth will store and return with the product. This enables applications to associate custom data such as feature flags, reference IDs, or other application-specific attributes with products. The implementation adds a new `productSchemaWithMetadata` schema, updates the product type handling in the backend, and includes comprehensive e2e tests verifying metadata is persisted and returned correctly through purchase creation, validation, and listing endpoints. ⏱️ Estimated Review Time: 15-30 minutes <details> <summary>💡 Review Order Suggestion</summary> | Order | File Path | |-------|-----------| | 1 | `packages/stack-shared/src/schema-fields.ts` | | 2 | `apps/backend/src/lib/payments.tsx` | | 3 | `apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts` | | 4 | `apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts` | | 5 | `apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts` | </details> [](https://discord.gg/n3SsVDAW6U) [ <!-- RECURSEML_SUMMARY:END --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Products now support custom metadata (client, client read-only, and server) and expose these fields in inline product representations. * Metadata is preserved and propagated through purchase creation, validation, grants, and owned-product listings so it’s available after purchase. * **Tests** * Added end-to-end tests verifying metadata is accepted, persisted, and returned in purchase creation, validation, grant, and listing flows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Adds support for custom metadata in inline products, updating schemas and functions to handle metadata, with comprehensive tests verifying the changes. > > - **Behavior**: > - Adds support for custom metadata in inline products, allowing arbitrary metadata attachment. > - Updates `ensureProductIdOrInlineProduct()` and `productToInlineProduct()` in `payments.tsx` to handle metadata. > - Metadata is preserved and returned in purchase creation, validation, and listing endpoints. > - **Schemas**: > - Adds `productSchemaWithMetadata` in `schema-fields.ts` to include `clientMetadata`, `clientReadOnlyMetadata`, and `serverMetadata`. > - Updates `inlineProductSchema` to support metadata fields. > - **Tests**: > - Adds e2e tests in `purchase-session.test.ts`, `create-purchase-url.test.ts`, and `products.test.ts` to verify metadata handling. > > <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> for1b5601c991. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> <!-- ELLIPSIS_HIDDEN --> --------- Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
parent
6dcb3f5574
commit
6d9c2b1fea
@ -1,7 +1,7 @@
|
||||
import { PrismaClientTransaction } from "@/prisma-client";
|
||||
import { PurchaseCreationSource, SubscriptionStatus } from "@prisma/client";
|
||||
import { KnownErrors } from "@stackframe/stack-shared";
|
||||
import type { inlineProductSchema, productSchema } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import type { inlineProductSchema, productSchema, productSchemaWithMetadata } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
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";
|
||||
@ -16,6 +16,7 @@ import { getStripeForAccount } from "./stripe";
|
||||
const DEFAULT_PRODUCT_START_DATE = new Date("1973-01-01T12:00:00.000Z"); // monday
|
||||
|
||||
type Product = yup.InferType<typeof productSchema>;
|
||||
type ProductWithMetadata = yup.InferType<typeof productSchemaWithMetadata>;
|
||||
type SelectedPrice = Exclude<Product["prices"], "include-by-default">[string];
|
||||
|
||||
export async function ensureProductIdOrInlineProduct(
|
||||
@ -23,7 +24,7 @@ export async function ensureProductIdOrInlineProduct(
|
||||
accessType: "client" | "server" | "admin",
|
||||
productId: string | undefined,
|
||||
inlineProduct: yup.InferType<typeof inlineProductSchema> | undefined
|
||||
): Promise<Tenancy["config"]["payments"]["products"][string]> {
|
||||
): Promise<ProductWithMetadata> {
|
||||
if (productId && inlineProduct) {
|
||||
throw new StatusError(400, "Cannot specify both product_id and product_inline!");
|
||||
}
|
||||
@ -61,6 +62,9 @@ export async function ensureProductIdOrInlineProduct(
|
||||
freeTrial: value.free_trial,
|
||||
serverOnly: true,
|
||||
}])),
|
||||
clientMetadata: inlineProduct.client_metadata ?? undefined,
|
||||
clientReadOnlyMetadata: inlineProduct.client_read_only_metadata ?? undefined,
|
||||
serverMetadata: inlineProduct.server_metadata ?? undefined,
|
||||
includedItems: typedFromEntries(Object.entries(inlineProduct.included_items).map(([key, value]) => [key, {
|
||||
repeat: value.repeat ?? "never",
|
||||
quantity: value.quantity ?? 0,
|
||||
@ -420,13 +424,16 @@ export async function ensureCustomerExists(options: {
|
||||
}
|
||||
}
|
||||
|
||||
export function productToInlineProduct(product: Product): yup.InferType<typeof inlineProductSchema> {
|
||||
export function productToInlineProduct(product: ProductWithMetadata): yup.InferType<typeof inlineProductSchema> {
|
||||
return {
|
||||
display_name: product.displayName ?? "Product",
|
||||
customer_type: product.customerType,
|
||||
stackable: product.stackable === true,
|
||||
server_only: product.serverOnly === true,
|
||||
included_items: product.includedItems,
|
||||
client_metadata: product.clientMetadata ?? null,
|
||||
client_read_only_metadata: product.clientReadOnlyMetadata ?? null,
|
||||
server_metadata: product.serverMetadata ?? null,
|
||||
prices: product.prices === "include-by-default" ? {} : typedFromEntries(typedEntries(product.prices).map(([key, value]) => [key, filterUndefined({
|
||||
...typedFromEntries(SUPPORTED_CURRENCIES.map(c => [c.code, getOrUndefined(value, c.code)])),
|
||||
interval: value.interval,
|
||||
@ -552,7 +559,7 @@ export async function grantProductToCustomer(options: {
|
||||
tenancy: Tenancy,
|
||||
customerType: "user" | "team" | "custom",
|
||||
customerId: string,
|
||||
product: Product,
|
||||
product: ProductWithMetadata,
|
||||
quantity: number,
|
||||
productId: string | undefined,
|
||||
priceId: string | undefined,
|
||||
@ -691,7 +698,7 @@ export async function getOwnedProductsForCustomer(options: {
|
||||
}
|
||||
|
||||
for (const purchase of oneTimePurchases) {
|
||||
const product = purchase.product as Product;
|
||||
const product = purchase.product as ProductWithMetadata;
|
||||
ownedProducts.push({
|
||||
id: purchase.productId ?? null,
|
||||
type: "one_time",
|
||||
|
||||
@ -41,6 +41,8 @@ it("should allow valid code and return offer data", async ({ expect }) => {
|
||||
"charges_enabled": false,
|
||||
"conflicting_products": [],
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Test Product",
|
||||
"included_items": {},
|
||||
@ -53,6 +55,7 @@ it("should allow valid code and return offer data", async ({ expect }) => {
|
||||
],
|
||||
},
|
||||
},
|
||||
"server_metadata": null,
|
||||
"server_only": false,
|
||||
"stackable": false,
|
||||
},
|
||||
@ -221,6 +224,8 @@ it("should include conflicting_group_offers when switching within the same group
|
||||
},
|
||||
],
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Offer B",
|
||||
"included_items": {},
|
||||
@ -233,6 +238,7 @@ it("should include conflicting_group_offers when switching within the same group
|
||||
],
|
||||
},
|
||||
},
|
||||
"server_metadata": null,
|
||||
"server_only": false,
|
||||
"stackable": false,
|
||||
},
|
||||
|
||||
@ -279,6 +279,89 @@ it("should allow product_inline when calling from server", async ({ expect }) =>
|
||||
expect(response.body.url).toMatch(new RegExp(`^https?:\\/\\/localhost:${withPortPrefix("01")}\/purchase\/[a-z0-9-_]+$`));
|
||||
});
|
||||
|
||||
it("should return inline product metadata when validating purchase code", async ({ expect }) => {
|
||||
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
|
||||
await Payments.setup();
|
||||
|
||||
const { userId } = await Auth.Otp.signIn();
|
||||
const createResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
customer_type: "user",
|
||||
customer_id: userId,
|
||||
product_inline: {
|
||||
display_name: "Metadata Inline Product",
|
||||
customer_type: "user",
|
||||
server_only: true,
|
||||
prices: {
|
||||
"monthly-metadata": {
|
||||
USD: "1500",
|
||||
interval: [1, "month"],
|
||||
},
|
||||
},
|
||||
included_items: {},
|
||||
server_metadata: {
|
||||
reference_id: "ref-123",
|
||||
features: ["priority-support", "analytics"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(createResponse.status).toBe(200);
|
||||
const url = (createResponse.body as { url: string }).url;
|
||||
const codeMatch = url.match(/\/purchase\/([a-z0-9-_]+)/);
|
||||
const fullCode = codeMatch ? codeMatch[1] : undefined;
|
||||
expect(fullCode).toBeDefined();
|
||||
|
||||
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: {
|
||||
full_code: fullCode,
|
||||
},
|
||||
});
|
||||
expect(validateResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"already_bought_non_stackable": false,
|
||||
"charges_enabled": false,
|
||||
"conflicting_products": [],
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Metadata Inline Product",
|
||||
"included_items": {},
|
||||
"prices": {
|
||||
"monthly-metadata": {
|
||||
"USD": "1500",
|
||||
"interval": [
|
||||
1,
|
||||
"month",
|
||||
],
|
||||
},
|
||||
},
|
||||
"server_metadata": {
|
||||
"features": [
|
||||
"priority-support",
|
||||
"analytics",
|
||||
],
|
||||
"reference_id": "ref-123",
|
||||
},
|
||||
"server_only": true,
|
||||
"stackable": false,
|
||||
},
|
||||
"project_id": "<stripped UUID>",
|
||||
"stripe_account_id": <stripped field 'stripe_account_id'>,
|
||||
"test_mode": true,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should allow valid product_id", async ({ expect }) => {
|
||||
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
|
||||
await Payments.setup();
|
||||
|
||||
@ -108,6 +108,8 @@ it("should grant configured subscription product and expose it via listing", asy
|
||||
{
|
||||
"id": "pro-plan",
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Pro Plan",
|
||||
"included_items": {},
|
||||
@ -120,6 +122,7 @@ it("should grant configured subscription product and expose it via listing", asy
|
||||
],
|
||||
},
|
||||
},
|
||||
"server_metadata": null,
|
||||
"server_only": false,
|
||||
"stackable": false,
|
||||
},
|
||||
@ -200,6 +203,8 @@ it("should hide server-only products from clients while exposing them to servers
|
||||
{
|
||||
"id": "server-plan",
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Server Plan",
|
||||
"included_items": {},
|
||||
@ -212,6 +217,7 @@ it("should hide server-only products from clients while exposing them to servers
|
||||
],
|
||||
},
|
||||
},
|
||||
"server_metadata": null,
|
||||
"server_only": true,
|
||||
"stackable": false,
|
||||
},
|
||||
@ -327,6 +333,8 @@ it("should allow granting stackable product with custom quantity", async ({ expe
|
||||
{
|
||||
"id": "stackable-plan",
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Stackable Plan",
|
||||
"included_items": {},
|
||||
@ -339,6 +347,7 @@ it("should allow granting stackable product with custom quantity", async ({ expe
|
||||
],
|
||||
},
|
||||
},
|
||||
"server_metadata": null,
|
||||
"server_only": false,
|
||||
"stackable": true,
|
||||
},
|
||||
@ -372,6 +381,10 @@ it("should grant inline product without needing configuration", async ({ expect
|
||||
},
|
||||
},
|
||||
included_items: {},
|
||||
server_metadata: {
|
||||
cohort: "beta",
|
||||
flags: ["inline-grant"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -389,6 +402,8 @@ it("should grant inline product without needing configuration", async ({ expect
|
||||
{
|
||||
"id": null,
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Inline Access",
|
||||
"included_items": {},
|
||||
@ -401,6 +416,10 @@ it("should grant inline product without needing configuration", async ({ expect
|
||||
],
|
||||
},
|
||||
},
|
||||
"server_metadata": {
|
||||
"cohort": "beta",
|
||||
"flags": ["inline-grant"],
|
||||
},
|
||||
"server_only": true,
|
||||
"stackable": false,
|
||||
},
|
||||
@ -685,6 +704,8 @@ it("listing products should list both subscription and one-time products", async
|
||||
{
|
||||
"id": "subscription-plan",
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Subscription Plan",
|
||||
"included_items": {},
|
||||
@ -697,6 +718,7 @@ it("listing products should list both subscription and one-time products", async
|
||||
],
|
||||
},
|
||||
},
|
||||
"server_metadata": null,
|
||||
"server_only": false,
|
||||
"stackable": false,
|
||||
},
|
||||
@ -705,10 +727,13 @@ it("listing products should list both subscription and one-time products", async
|
||||
{
|
||||
"id": "lifetime-addon",
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Lifetime Add-on",
|
||||
"included_items": {},
|
||||
"prices": { "lifetime": { "USD": "5000" } },
|
||||
"server_metadata": null,
|
||||
"server_only": false,
|
||||
"stackable": false,
|
||||
},
|
||||
@ -813,6 +838,8 @@ it("listing products should support cursor pagination", async ({ expect }) => {
|
||||
{
|
||||
"id": "subscription-plan",
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Subscription Plan",
|
||||
"included_items": {},
|
||||
@ -825,6 +852,7 @@ it("listing products should support cursor pagination", async ({ expect }) => {
|
||||
],
|
||||
},
|
||||
},
|
||||
"server_metadata": null,
|
||||
"server_only": false,
|
||||
"stackable": false,
|
||||
},
|
||||
@ -850,10 +878,13 @@ it("listing products should support cursor pagination", async ({ expect }) => {
|
||||
{
|
||||
"id": "lifetime-addon",
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Lifetime Add-on",
|
||||
"included_items": {},
|
||||
"prices": { "lifetime": { "USD": "5000" } },
|
||||
"server_metadata": null,
|
||||
"server_only": false,
|
||||
"stackable": false,
|
||||
},
|
||||
@ -862,10 +893,13 @@ it("listing products should support cursor pagination", async ({ expect }) => {
|
||||
{
|
||||
"id": "pro-addon",
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Pro Add-on",
|
||||
"included_items": {},
|
||||
"prices": { "standard": { "USD": "7000" } },
|
||||
"server_metadata": null,
|
||||
"server_only": false,
|
||||
"stackable": false,
|
||||
},
|
||||
|
||||
@ -437,6 +437,79 @@ it("creates subscription in test mode and increases included item quantity", asy
|
||||
expect(getAfter.body.quantity).toBe(2);
|
||||
});
|
||||
|
||||
it("should list inline product metadata after completing test-mode purchase", async ({ expect }) => {
|
||||
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
|
||||
await Payments.setup();
|
||||
await Project.updateConfig({
|
||||
payments: {
|
||||
testMode: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { userId } = await Auth.Otp.signIn();
|
||||
const createPurchaseResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
customer_type: "user",
|
||||
customer_id: userId,
|
||||
product_inline: {
|
||||
display_name: "Inline Metadata Product",
|
||||
customer_type: "user",
|
||||
server_only: true,
|
||||
prices: {
|
||||
"monthly-inline": {
|
||||
USD: "1800",
|
||||
interval: [1, "month"],
|
||||
},
|
||||
},
|
||||
included_items: {},
|
||||
server_metadata: {
|
||||
correlation_id: "inline-test-123",
|
||||
attributes: {
|
||||
seats: 5,
|
||||
tier: "gold",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(createPurchaseResponse.status).toBe(200);
|
||||
const url = (createPurchaseResponse.body as { url: string }).url;
|
||||
const codeMatch = url.match(/\/purchase\/([a-z0-9-_]+)/);
|
||||
const code = codeMatch ? codeMatch[1] : undefined;
|
||||
expect(code).toBeDefined();
|
||||
|
||||
const testModePurchaseResponse = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
full_code: code,
|
||||
price_id: "monthly-inline",
|
||||
},
|
||||
});
|
||||
expect(testModePurchaseResponse.status).toBe(200);
|
||||
expect(testModePurchaseResponse.body).toEqual({ success: true });
|
||||
|
||||
const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
|
||||
accessType: "server",
|
||||
});
|
||||
expect(listResponse.status).toBe(200);
|
||||
const listBody = listResponse.body as {
|
||||
items: Array<{ product: { server_metadata?: Record<string, unknown> } }>,
|
||||
};
|
||||
expect(listBody.items).toHaveLength(1);
|
||||
expect(listBody.items[0].product.server_metadata).toMatchInlineSnapshot(`
|
||||
{
|
||||
"attributes": {
|
||||
"seats": 5,
|
||||
"tier": "gold",
|
||||
},
|
||||
"correlation_id": "inline-test-123",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("test-mode should error on invalid code", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
const response = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
|
||||
|
||||
@ -41,6 +41,8 @@ it("should allow valid code and return product data", async ({ expect }) => {
|
||||
"charges_enabled": false,
|
||||
"conflicting_products": [],
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Test Product",
|
||||
"included_items": {},
|
||||
@ -53,6 +55,7 @@ it("should allow valid code and return product data", async ({ expect }) => {
|
||||
],
|
||||
},
|
||||
},
|
||||
"server_metadata": null,
|
||||
"server_only": false,
|
||||
"stackable": false,
|
||||
},
|
||||
@ -221,6 +224,8 @@ it("should include conflicting_products when switching within the same group", a
|
||||
},
|
||||
],
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Product B",
|
||||
"included_items": {},
|
||||
@ -233,6 +238,7 @@ it("should include conflicting_products when switching within the same group", a
|
||||
],
|
||||
},
|
||||
},
|
||||
"server_metadata": null,
|
||||
"server_only": false,
|
||||
"stackable": false,
|
||||
},
|
||||
@ -313,6 +319,8 @@ it("should reject untrusted return_url and accept trusted return_url", async ({
|
||||
"charges_enabled": false,
|
||||
"conflicting_products": [],
|
||||
"product": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"customer_type": "user",
|
||||
"display_name": "Test Product",
|
||||
"included_items": {},
|
||||
@ -325,6 +333,7 @@ it("should reject untrusted return_url and accept trusted return_url", async ({
|
||||
],
|
||||
},
|
||||
},
|
||||
"server_metadata": null,
|
||||
"server_only": false,
|
||||
"stackable": false,
|
||||
},
|
||||
|
||||
@ -590,6 +590,19 @@ export const productSchema = yupObject({
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const productMetadataExample = { featureFlag: true, source: 'marketing-campaign' } as const;
|
||||
|
||||
export const productClientMetadataSchema = jsonSchema.meta({ openapiField: { description: _clientMetaDataDescription('product'), exampleValue: productMetadataExample } });
|
||||
export const productClientReadOnlyMetadataSchema = jsonSchema.meta({ openapiField: { description: _clientReadOnlyMetaDataDescription('product'), exampleValue: productMetadataExample } });
|
||||
export const productServerMetadataSchema = jsonSchema.meta({ openapiField: { description: _serverMetaDataDescription('product'), exampleValue: productMetadataExample } });
|
||||
|
||||
export const productSchemaWithMetadata = productSchema.concat(yupObject({
|
||||
clientMetadata: productClientMetadataSchema.optional(),
|
||||
clientReadOnlyMetadata: productClientReadOnlyMetadataSchema.optional(),
|
||||
serverMetadata: productServerMetadataSchema.optional(),
|
||||
}));
|
||||
|
||||
export const inlineProductSchema = yupObject({
|
||||
display_name: yupString().defined(),
|
||||
customer_type: customerTypeSchema.defined(),
|
||||
@ -612,6 +625,9 @@ export const inlineProductSchema = yupObject({
|
||||
expires: yupString().oneOf(['never', 'when-purchase-expires', 'when-repeated']).optional(),
|
||||
}),
|
||||
),
|
||||
client_metadata: productClientMetadataSchema.optional(),
|
||||
client_read_only_metadata: productClientReadOnlyMetadataSchema.optional(),
|
||||
server_metadata: productServerMetadataSchema.optional(),
|
||||
});
|
||||
|
||||
// Users
|
||||
|
||||
Loading…
Reference in New Issue
Block a user