inline product metadata

This commit is contained in:
Bilal Godil 2025-10-17 10:26:14 -07:00
parent 1751ea424d
commit d42cf54997
5 changed files with 150 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,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<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,
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",

View File

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

View File

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

View File

@ -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<string, unknown> } }>,
};
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", {

View File

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