From 6d9c2b1feae6fcf831fa2a1a06d1b365d55cd5f0 Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Mon, 27 Oct 2025 10:03:44 -0700 Subject: [PATCH 01/10] inline product metadata (#963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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
πŸ’‘ Review Order Suggestion | 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` |
[![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/2549bec1b95e3e2cba3dd33a3d895a701da579e40ee40ce66e129b598d8a5c75/?repo_owner=stack-auth&repo_name=stack-auth&pr_number=963) ## 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. ---- > [!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. > > This description was created by [Ellipsis](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral) for 1b5601c991e524a957f6123f72aaeff5b913a211. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed. --------- Co-authored-by: Konsti Wohlwend --- apps/backend/src/lib/payments.tsx | 17 ++-- .../outdated--validate-code.test.ts | 6 ++ .../v1/payments/create-purchase-url.test.ts | 83 +++++++++++++++++++ .../api/v1/payments/products.test.ts | 34 ++++++++ .../api/v1/payments/purchase-session.test.ts | 73 ++++++++++++++++ .../api/v1/payments/validate-code.test.ts | 9 ++ packages/stack-shared/src/schema-fields.ts | 16 ++++ 7 files changed, 233 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 25c913dc3..1865db6ba 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,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 { +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, + 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", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts index ef63b9980..1e507627a 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts @@ -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, }, 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 a649b5208..26d329be5 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 @@ -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": "", + "stripe_account_id": , + "test_mode": true, + }, + "headers": Headers {