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>



[![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](2549bec1b9/?repo_owner=stack-auth&repo_name=stack-auth&pr_number=963)
<!-- 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>
for 1b5601c991. 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:
BilalG1 2025-10-27 10:03:44 -07:00 committed by GitHub
parent 6dcb3f5574
commit 6d9c2b1fea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 233 additions and 5 deletions

View File

@ -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",

View File

@ -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,
},

View File

@ -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();

View File

@ -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,
},

View File

@ -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", {

View File

@ -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,
},

View File

@ -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