mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
inline product metadata
This commit is contained in:
parent
1751ea424d
commit
d42cf54997
@ -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",
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user