From d42cf54997399c85c87388140e2d8992a35f621b Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 17 Oct 2025 10:26:14 -0700 Subject: [PATCH 1/3] inline product metadata --- apps/backend/src/lib/payments.tsx | 13 ++-- .../v1/payments/create-purchase-url.test.ts | 55 ++++++++++++++ .../api/v1/payments/products.test.ts | 8 ++ .../api/v1/payments/purchase-session.test.ts | 73 +++++++++++++++++++ packages/stack-shared/src/schema-fields.ts | 6 ++ 5 files changed, 150 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 25c913dc33..c110df0c0c 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -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; +type ProductWithMetadata = yup.InferType; type SelectedPrice = Exclude[string]; export async function ensureProductIdOrInlineProduct( @@ -23,7 +24,7 @@ export async function ensureProductIdOrInlineProduct( accessType: "client" | "server" | "admin", productId: string | undefined, inlineProduct: yup.InferType | undefined -): Promise { +): Promise { if (productId && inlineProduct) { throw new StatusError(400, "Cannot specify both product_id and product_inline!"); } @@ -61,6 +62,7 @@ export async function ensureProductIdOrInlineProduct( freeTrial: value.free_trial, serverOnly: true, }])), + metadata: inlineProduct.metadata, includedItems: typedFromEntries(Object.entries(inlineProduct.included_items).map(([key, value]) => [key, { repeat: value.repeat ?? "never", quantity: value.quantity ?? 0, @@ -420,13 +422,14 @@ export async function ensureCustomerExists(options: { } } -export function productToInlineProduct(product: Product): yup.InferType { +export function productToInlineProduct(product: ProductWithMetadata): yup.InferType { return { display_name: product.displayName ?? "Product", customer_type: product.customerType, stackable: product.stackable === true, server_only: product.serverOnly === true, included_items: product.includedItems, + metadata: product.metadata, 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 +555,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 +694,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", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts index 59bd816e3d..7198f4024f 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts @@ -278,6 +278,61 @@ it("should allow product_inline when calling from server", async ({ expect }) => expect(response.body.url).toMatch(/^https?:\/\/localhost:8101\/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: {}, + 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.status).toBe(200); + const validateBody = validateResponse.body; + expect(validateBody.product.metadata).toMatchInlineSnapshot(` + { + "features": [ + "priority-support", + "analytics", + ], + "reference_id": "ref-123", + } + `); +}); + it("should allow valid product_id", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Payments.setup(); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts index 58eca8ea43..f4263230f6 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts @@ -372,6 +372,10 @@ it("should grant inline product without needing configuration", async ({ expect }, }, included_items: {}, + metadata: { + cohort: "beta", + flags: ["inline-grant"], + }, }, }, }); @@ -392,6 +396,10 @@ it("should grant inline product without needing configuration", async ({ expect "customer_type": "user", "display_name": "Inline Access", "included_items": {}, + "metadata": { + "cohort": "beta", + "flags": ["inline-grant"], + }, "prices": { "quarterly": { "USD": "2400", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts index 539df487df..a97f961737 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts @@ -436,6 +436,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: {}, + 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: { metadata?: Record } }>, + }; + expect(listBody.items).toHaveLength(1); + expect(listBody.items[0].product.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", { diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index d95b714321..ec6970ad38 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -590,6 +590,11 @@ export const productSchema = yupObject({ }), ), }); + +const productMetadata = jsonSchema.optional().meta({ openapiField: { description: 'Optional metadata that Stack Auth will store and return with this product. Use this to attach custom data needed by your application.', exampleValue: { featureFlag: true, source: 'marketing-campaign' } } }); + +export const productSchemaWithMetadata = productSchema.concat(yupObject({ metadata: productMetadata })); + export const inlineProductSchema = yupObject({ display_name: yupString().defined(), customer_type: customerTypeSchema.defined(), @@ -612,6 +617,7 @@ export const inlineProductSchema = yupObject({ expires: yupString().oneOf(['never', 'when-purchase-expires', 'when-repeated']).optional(), }), ), + metadata: productMetadata, }); // Users From 20c9691d4fe4174a539bf151b5c839505f72511d Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 17 Oct 2025 14:51:57 -0700 Subject: [PATCH 2/3] client, clientReadOnly, server --- apps/backend/src/lib/payments.tsx | 8 +++- .../v1/payments/create-purchase-url.test.ts | 48 +++++++++++++++---- .../api/v1/payments/products.test.ts | 36 ++++++++++++-- .../api/v1/payments/purchase-session.test.ts | 6 +-- packages/stack-shared/src/schema-fields.ts | 16 +++++-- 5 files changed, 91 insertions(+), 23 deletions(-) diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index c110df0c0c..1865db6ba9 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -62,7 +62,9 @@ export async function ensureProductIdOrInlineProduct( freeTrial: value.free_trial, serverOnly: true, }])), - metadata: inlineProduct.metadata, + 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, @@ -429,7 +431,9 @@ export function productToInlineProduct(product: ProductWithMetadata): yup.InferT stackable: product.stackable === true, server_only: product.serverOnly === true, included_items: product.includedItems, - metadata: product.metadata, + 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, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts index 7198f4024f..3281302010 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts @@ -300,7 +300,7 @@ it("should return inline product metadata when validating purchase code", async }, }, included_items: {}, - metadata: { + server_metadata: { reference_id: "ref-123", features: ["priority-support", "analytics"], }, @@ -320,15 +320,43 @@ it("should return inline product metadata when validating purchase code", async full_code: fullCode, }, }); - expect(validateResponse.status).toBe(200); - const validateBody = validateResponse.body; - expect(validateBody.product.metadata).toMatchInlineSnapshot(` - { - "features": [ - "priority-support", - "analytics", - ], - "reference_id": "ref-123", + 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": "", + "stripe_account_id": , + "test_mode": true, + }, + "headers": Headers {