chore: cleaned up openapi for products endpoint group

This commit is contained in:
John Yeo
2025-10-24 16:16:09 +01:00
parent 55ac51197a
commit 8183ad1b5f
8 changed files with 421 additions and 354 deletions

View File

@@ -90,7 +90,7 @@ export const constructProduct = ({
is_add_on: productData.is_add_on,
is_default: productData.is_default,
version: productData.version || 1,
group: productData.group,
group: productData.group || "",
env,
internal_id: generateId("prod"),

View File

@@ -1,28 +1,22 @@
import "dotenv/config";
import { execSync } from "node:child_process";
import { existsSync, writeFileSync } from "node:fs";
import { AppEnv } from "@models/genModels/genEnums.js";
import yaml from "yaml";
import { z } from "zod/v4";
import { createDocument } from "zod-openapi";
import { CustomerDataSchema } from "./common/customerData.js";
import { EntityDataSchema } from "./common/entityData.js";
import { coreOps } from "./core/coreOpenApi.js";
import { ApiCusFeatureSchema } from "./customers/cusFeatures/apiCusFeature.js";
import { ApiCusProductSchema } from "./customers/cusProducts/apiCusProduct.js";
import {
ApiCustomerWithMeta,
customerOps,
} from "./customers/customersOpenApi.js";
import { ApiEntityWithMeta, entityOps } from "./entities/entitiesOpenApi.js";
import { ApiFeatureWithMeta, featureOps } from "./features/featuresOpenApi.js";
import { ApiCustomerWithMeta } from "./customers/customersOpenApi.js";
import { ApiEntityWithMeta } from "./entities/entitiesOpenApi.js";
import { ApiFeatureWithMeta } from "./features/featuresOpenApi.js";
import { ApiProductItemSchema } from "./products/apiProductItem.js";
import { ApiProductWithMeta, productOps } from "./products/productsOpenApi.js";
import { referralOps } from "./referrals/referralsOpenApi.js";
const API_VERSION = "1.2.0";
const document = createDocument({
const document = createDocument(
{
openapi: "3.1.0",
info: {
title: "Autumn API",
@@ -43,16 +37,6 @@ const document = createDocument({
],
components: {
schemas: {
AutumnError: z
.object({
message: z.string(),
code: z.string(),
env: z.enum(AppEnv),
})
.meta({
id: "AutumnError",
description: "An error that occurred in the API",
}),
CustomerData: CustomerDataSchema.meta({
id: "CustomerData",
description: "Customer data for creating or updating a customer",
@@ -83,17 +67,37 @@ const document = createDocument({
paths: {
...productOps,
...coreOps,
...featureOps,
...customerOps,
...entityOps,
...referralOps,
// ...coreOps,
// ...featureOps,
// ...customerOps,
// ...entityOps,
// ...referralOps,
},
});
},
{
// Disable the "Output" suffix that zod-openapi adds to response schemas
outputIdSuffix: "",
},
);
// Export to YAML file during build
if (process.env.NODE_ENV !== "production") {
try {
// If --no-build flag is present, return after writing openapi.yml
if (process.argv.includes("--no-build")) {
const yamlContent = yaml.stringify(
JSON.parse(JSON.stringify(document, null, 2)),
);
writeFileSync(
`${process.env.STAINLESS_PATH?.replace("\\ ", " ")}/openapi.yml`,
yamlContent,
"utf8",
);
console.log(
`OpenAPI document exported to ${process.env.STAINLESS_PATH}/openapi.yml`,
);
process.exit(0);
}
// Convert to JSON first to strip out Zod schemas and function references
const jsonStr = JSON.stringify(document, null, 2);

View File

@@ -4,26 +4,24 @@ import { z } from "zod/v4";
export const APIFreeTrialSchema = z.object({
duration: z.enum(FreeTrialDuration).meta({
description: "The duration type of the free trial",
example: "<string>",
}),
length: z.number().meta({
description: "The length of the free trial",
example: 123,
description: "The length of the duration type specified",
}),
unique_fingerprint: z.boolean().meta({
description:
"Whether the free trial is limited to one per customer fingerprint",
example: true,
}),
card_required: z.boolean().meta({
description: "Whether the free trial requires a card",
example: true,
description:
"Whether the free trial requires a card. If false, the customer can attach the product without going through a checkout flow or having a card on file.",
}),
// For Cus Product
trial_available: z.boolean().nullish().default(true).meta({
description: "Whether the free trial is available",
example: true,
description:
"Used in customer context. Whether the free trial is available for the customer if they were to attach the product.",
}),
});

View File

@@ -4,6 +4,105 @@ import { z } from "zod/v4";
import { APIFreeTrialSchema } from "./apiFreeTrial.js";
import { ApiProductItemSchema } from "./apiProductItem.js";
export const PRODUCT_EXAMPLE = {
id: "Pro Product",
name: "Pro Plan",
group: null,
env: "sandbox",
is_add_on: false,
is_default: false,
archived: false,
version: 1,
created_at: 1761296829908,
items: [
{
type: "price",
feature_id: null,
interval: "month",
interval_count: 1,
price: 20,
display: {
primary_text: "$20",
secondary_text: "per month",
},
},
{
type: "priced_feature",
feature_id: "words",
included_usage: 1000,
interval: "month",
interval_count: 1,
price: 0.5,
usage_model: "pay_per_use",
billing_units: 1000,
reset_usage_when_enabled: true,
entity_feature_id: null,
display: {
primary_text: "1,000 Words",
secondary_text: "then $0.5 per 1,000 Words",
},
},
{
type: "feature",
feature_id: "dashboard",
// feature_type: "static",
// feature: {
// id: "dashboard",
// name: "Dashboard",
// type: "boolean",
// display: {
// singular: "Dashboard",
// plural: "Dashboard",
// },
// archived: false,
// },
entity_feature_id: null,
display: {
primary_text: "Dashboard",
},
},
{
type: "feature",
feature_id: "messages",
// feature_type: "single_use",
// feature: {
// id: "messages",
// name: "Messages",
// type: "single_use",
// display: {
// singular: "Messages",
// plural: "Messages",
// },
// archived: false,
// },
included_usage: 10,
interval: "month",
interval_count: 1,
reset_usage_when_enabled: true,
entity_feature_id: null,
display: {
primary_text: "10 Messages",
},
},
],
free_trial: {
duration: "day",
length: 7,
unique_fingerprint: false,
card_required: true,
},
base_variant_id: null,
scenario: "new",
// properties: {
// is_free: false,
// is_one_off: false,
// interval_group: "month",
// has_trial: false,
// updateable: false,
// },
};
export const ApiProductPropertiesSchema = z.object({
is_free: z.boolean().meta({
description: "True if the product has no base price or usage prices",
@@ -30,107 +129,64 @@ export const ApiProductPropertiesSchema = z.object({
});
export const ApiProductSchema = z.object({
id: z.string().meta({
description: "The ID of the product you set when creating the product",
example: "pro_plan",
}),
id: z
.string()
.describe("The ID of the product you set when creating the product"),
name: z.string().meta({
description: "The name of the product",
example: "Pro Plan",
}),
name: z.string().describe("The name of the product"),
group: z.string().nullable().meta({
description: "The group of the product",
example: "product_set_1",
}),
group: z
.string()
.nullable()
.describe("Product group which this product belongs to"),
env: z.enum(AppEnv).meta({
description: "The environment of the product",
example: "production",
}),
env: z.enum(AppEnv).describe("The environment of the product"),
is_add_on: z.boolean().meta({
description:
is_add_on: z
.boolean()
.describe(
"Whether the product is an add-on and can be purchased alongside other products",
example: true,
}),
),
is_default: z.boolean().meta({
description: "Whether the product is the default product",
example: true,
}),
is_default: z
.boolean()
.describe("Whether the product is the default product"),
archived: z.boolean({ message: "archived should be a boolean" }).meta({
description:
archived: z
.boolean({ message: "archived should be a boolean" })
.describe(
"Whether this product has been archived and is no longer available",
example: false,
}),
),
version: z.number().meta({
description: "The version of the product",
example: 1,
}),
version: z.number().describe("The current version of the product"),
created_at: z.number().meta({
description:
created_at: z
.number()
.describe(
"The timestamp of when the product was created in milliseconds since epoch",
example: 1759247877000,
}),
),
items: z.array(ApiProductItemSchema).meta({
description: "Array of product items that define the features and pricing",
example: [
{
feature_id: "<string>",
feature_type: "single_use",
included_usage: 123,
interval: "<string>",
usage_model: "prepaid",
price: 123,
billing_units: 123,
entity_feature_id: "<string>",
reset_usage_when_enabled: true,
tiers: [
{
to: 123,
amount: 123,
},
],
},
],
}),
items: z
.array(ApiProductItemSchema)
.describe(
"Array of product items that define the product's features and pricing",
),
free_trial: APIFreeTrialSchema.nullable().meta({
description: "Free trial configuration for this product, if available",
example: {
duration: "<string>",
length: 123,
unique_fingerprint: true,
},
}),
free_trial: APIFreeTrialSchema.nullable().describe(
"Free trial configuration for this product, if available",
),
base_variant_id: z.string().nullable().meta({
description: "ID of the base variant this product is derived from",
example: "pro_monthly",
}),
base_variant_id: z
.string()
.nullable()
.describe("ID of the base variant this product is derived from"),
scenario: z.enum(AttachScenario).optional().meta({
description:
"Scenario context for when this product is used in attach flows",
example: "upgrade",
}),
scenario: z
.enum(AttachScenario)
.optional()
.describe("Scenario for when this product is used in attach flows"),
properties: ApiProductPropertiesSchema.optional().meta({
description: "Additional properties and metadata for the product",
example: {
is_free: false,
is_one_off: false,
interval_group: "monthly",
has_trial: true,
updateable: true,
},
}),
properties: ApiProductPropertiesSchema.optional(),
});
export type ApiProduct = z.infer<typeof ApiProductSchema>;

View File

@@ -12,64 +12,61 @@ import { z } from "zod/v4";
export const ApiProductItemSchema = z
.object({
// Feature stuff
type: z.enum(ProductItemType).nullish().meta({
description: "The type of the product item",
example: "<string>",
}),
feature_id: z.string().nullish().meta({
description:
"The feature ID of the product item. Should be `null` for prices.",
example: "<string>",
}),
type: z
.enum(ProductItemType)
.nullish()
.describe("The type of the product item"),
feature_id: z
.string()
.nullish()
.describe(
"The feature ID of the product item. If the item is a fixed price, should be `null`",
),
feature_type: z.enum(ProductItemFeatureType).nullish().meta({
description:
"Single use features are used once and then depleted, like API calls or credits. Continuous use features are those being used on an ongoing-basis, like storage or seats.",
example: "<string>",
}),
// Feature response
feature: ApiFeatureSchema.nullish().meta({
description: "The feature itself",
internal: true,
}),
included_usage: z.number().or(z.literal(Infinite)).nullish().meta({
description: "The amount of usage included for this feature.",
example: 123,
}),
interval: z.enum(ProductItemInterval).nullish().meta({
description:
"The reset or billing interval of the product item. If null, feature will have no reset date, and if there's a price, it will be billed one-off.",
}),
interval_count: z.number().nullish().meta({
description: "The number of intervals between resets",
description: "The interval count of the product item.",
}),
// Price config
price: z.number().nullish().meta({
description:
"The price of the product item. Should be `null` if tiered pricing is set.",
example: 123,
}),
tiers: z
.array(PriceTierSchema)
.nullish()
.meta({
description: "Tiered pricing for the product item.",
example: [
{ to: 100, amount: 10 },
{ to: 200, amount: 20 },
],
tiers: z.array(PriceTierSchema).nullish().meta({
description:
"Tiered pricing for the product item. Not applicable for fixed price items.",
}),
usage_model: z.enum(UsageModel).nullish().meta({
description:
"Whether the feature should be prepaid upfront or billed for how much they use end of billing period.",
example: "<string>",
}),
billing_units: z.number().nullish().meta({
description: "The amount per billing unit (eg. $9 / 250 units)",
example: 250,
}),
reset_usage_when_enabled: z.boolean().nullish().meta({
description:
"Whether the usage should be reset when the product is enabled.",
@@ -77,7 +74,6 @@ export const ApiProductItemSchema = z
entity_feature_id: z.string().nullish().meta({
description: "The entity feature ID of the product item if applicable.",
example: "<string>",
}),
display: z
@@ -88,16 +84,16 @@ export const ApiProductItemSchema = z
.nullish()
.meta({
description: "The display of the product item.",
example: { primary_text: "<string>", secondary_text: "<string>" },
}),
quantity: z.number().nullish().meta({
description: "The quantity of the product item.",
example: 123,
description:
"Used in customer context. Quantity of the feature the customer has prepaid for.",
}),
next_cycle_quantity: z.number().nullish().meta({
description: "The quantity of the product item in the next cycle.",
example: 123,
description:
"Used in customer context. Quantity of the feature the customer will prepay for in the next cycle.",
}),
})
.meta({

View File

@@ -3,7 +3,11 @@ import { ProductItemSchema } from "@models/productV2Models/productItemModels/pro
import { idRegex } from "@utils/utils.js";
import { z } from "zod/v4";
export const CreateProductItemParamsSchema = ProductItemSchema;
// Use the full ProductItemSchema but mark backend fields as internal
export const CreateProductItemParamsSchema = ProductItemSchema.meta({
id: "CreateProductItemParams",
description: "Product item defining features and pricing within a product",
});
// Base product params
@@ -35,43 +39,95 @@ const CREATE_PRODUCT_EXAMPLE = {
},
};
const descriptions = {
id: "The ID of the product. Used to identify the product in other API calls like checkout or update product.",
name: "The name of the product",
is_add_on:
"Whether the product is an add-on. Add-on products can be attached multiple times and don't to through upgrade / downgrade flows.",
is_default:
"Whether the product is the default product. Default products are enabled by default for new customers.",
group:
"Product group which this product belongs to. Products within a group have upgrade / downgrade logic when the customer moves between them.",
items:
"Array of product items that define the product's features and pricing",
free_trial: "Free trial configuration for this product, if available",
// Update only
archived:
"Archive this product using this flag. Archived products are hidden on the dashboard.",
};
export const CreateProductV2ParamsSchema = z
.object({
id: z.string().nonempty().regex(idRegex),
name: z.string().refine((val) => val.length > 0, {
message: "name must be a non-empty string",
id: z.string().nonempty().regex(idRegex).meta({
description: descriptions.id,
}),
is_add_on: z.boolean().default(false),
is_default: z.boolean().default(false),
version: z.number().optional(),
group: z.string().nullable().default(""),
name: z
.string()
.refine((val) => val.length > 0, {
message: "name must be a non-empty string",
})
.meta({
description: descriptions.name,
}),
items: z.array(CreateProductItemParamsSchema).optional(),
free_trial: CreateFreeTrialSchema.nullish().default(null),
is_add_on: z.boolean().default(false).meta({
description: descriptions.is_add_on,
}),
is_default: z.boolean().default(false).meta({
description: descriptions.is_default,
}),
group: z.string().nullable().default("").meta({
description: descriptions.group,
}),
items: z.array(CreateProductItemParamsSchema).optional().meta({
description: descriptions.items,
}),
free_trial: CreateFreeTrialSchema.nullish().default(null).meta({
description: descriptions.free_trial,
}),
})
.meta({
examples: [CREATE_PRODUCT_EXAMPLE],
});
export const UpdateProductV2ParamsSchema = z.object({
id: z.string().nonempty().regex(idRegex).optional(),
id: z.string().nonempty().regex(idRegex).optional().meta({
description: descriptions.id,
}),
name: z
.string()
.refine((val) => val.length > 0, {
message: "name must be a non-empty string",
})
.optional(),
.optional()
.meta({
description: descriptions.name,
}),
is_add_on: z.boolean().optional(),
is_default: z.boolean().optional(),
is_add_on: z.boolean().optional().meta({
description: descriptions.is_add_on,
}),
is_default: z.boolean().optional().meta({
description: descriptions.is_default,
}),
// version: z.number().optional(),
group: z.string().nonempty().nullable().optional(),
archived: z.boolean().optional(),
group: z.string().nonempty().nullable().optional().meta({
description: descriptions.group,
}),
archived: z.boolean().optional().meta({
description: descriptions.archived,
}),
items: z.array(CreateProductItemParamsSchema).optional(),
free_trial: CreateFreeTrialSchema.nullish(),
free_trial: CreateFreeTrialSchema.nullish().meta({
description: descriptions.free_trial,
}),
});
export const UpdateProductQuerySchema = z.object({

View File

@@ -1,120 +1,14 @@
import { SuccessResponseSchema } from "@api/common/commonResponses.js";
import { z } from "zod/v4";
import { ApiProductSchema, PRODUCT_EXAMPLE } from "./apiProduct.js";
import {
CreateProductV2ParamsSchema,
UpdateProductV2ParamsSchema,
} from "@api/models.js";
import { z } from "zod/v4";
import { ApiProductSchema } from "./apiProduct.js";
export const PRODUCT_EXAMPLE = {
id: "Pro Product",
name: "Pro Plan",
group: null,
env: "sandbox",
is_add_on: false,
is_default: false,
archived: false,
version: 1,
created_at: 1761296829908,
items: [
{
type: "price",
feature_id: null,
feature: null,
interval: "month",
interval_count: 1,
price: 20,
display: {
primary_text: "$20",
secondary_text: "per month",
},
},
{
type: "priced_feature",
feature_id: "words",
feature_type: "single_use",
// feature: {
// id: "words",
// name: "Words",
// type: "single_use",
// display: {
// singular: "Words",
// plural: "Words",
// },
// archived: false,
// },
included_usage: 1000,
interval: "month",
interval_count: 1,
price: 0.5,
usage_model: "pay_per_use",
billing_units: 1000,
reset_usage_when_enabled: true,
entity_feature_id: null,
display: {
primary_text: "1,000 Words",
secondary_text: "then $0.5 per 1,000 Words",
},
},
{
type: "feature",
feature_id: "dashboard",
feature_type: "static",
// feature: {
// id: "dashboard",
// name: "Dashboard",
// type: "boolean",
// display: {
// singular: "Dashboard",
// plural: "Dashboard",
// },
// archived: false,
// },
entity_feature_id: null,
display: {
primary_text: "Dashboard",
},
},
{
type: "feature",
feature_id: "messages",
feature_type: "single_use",
// feature: {
// id: "messages",
// name: "Messages",
// type: "single_use",
// display: {
// singular: "Messages",
// plural: "Messages",
// },
// archived: false,
// },
included_usage: 10,
interval: "month",
interval_count: 1,
reset_usage_when_enabled: true,
entity_feature_id: null,
display: {
primary_text: "10 Messages",
},
},
],
free_trial: null,
base_variant_id: null,
scenario: "new",
// properties: {
// is_free: false,
// is_one_off: false,
// interval_group: "month",
// has_trial: false,
// updateable: false,
// },
};
} from "./productOpModels.js";
// Register schema with .meta() for OpenAPI spec generation
export const ApiProductWithMeta = ApiProductSchema.meta({
id: "Product",
description: "A product",
examples: [PRODUCT_EXAMPLE],
});
@@ -130,7 +24,7 @@ export const productOps = {
},
responses: {
"200": {
description: "200 OK",
description: "",
content: {
"application/json": {
schema: z.object({
@@ -151,7 +45,7 @@ export const productOps = {
},
responses: {
"200": {
description: "200 OK",
description: "",
content: { "application/json": { schema: ApiProductWithMeta } },
},
},
@@ -168,7 +62,7 @@ export const productOps = {
},
responses: {
"200": {
description: "Product retrieved successfully",
description: "",
content: { "application/json": { schema: ApiProductWithMeta } },
},
},
@@ -188,7 +82,7 @@ export const productOps = {
},
responses: {
"200": {
description: "200 OK",
description: "",
content: { "application/json": { schema: ApiProductWithMeta } },
},
},
@@ -206,7 +100,7 @@ export const productOps = {
},
responses: {
"200": {
description: "Product deleted successfully",
description: "",
content: {
"application/json": {
schema: SuccessResponseSchema,

View File

@@ -61,48 +61,111 @@ export const RolloverConfigSchema = z.object({
});
const ProductItemConfigSchema = z.object({
on_increase: z.nativeEnum(OnIncrease).nullish(),
on_decrease: z.nativeEnum(OnDecrease).nullish(),
on_increase: z.enum(OnIncrease).nullish(),
on_decrease: z.enum(OnDecrease).nullish(),
rollover: RolloverConfigSchema.nullish(),
});
export const ProductItemSchema = z.object({
// Feature stuff
feature_id: z.string().nullish(),
feature_type: z.nativeEnum(ProductItemFeatureType).nullish(),
included_usage: z.union([z.number(), z.literal(Infinite)]).nullish(),
interval: z.preprocess((val) => {
feature_id: z.string().nullish().meta({
description:
"The feature ID of the product item. Should be null for fixed price items.",
}),
feature_type: z.enum(ProductItemFeatureType).nullish().meta({
internal: true,
}),
included_usage: z
.union([z.number(), z.literal(Infinite)])
.nullish()
.meta({
description:
"The amount of usage included for this feature (per interval).",
}),
interval: z
.preprocess((val) => {
if (val === "") {
throw new Error("Interval cannot be empty.");
}
return val;
}, z.enum(ProductItemInterval).nullish()),
interval_count: z.number().nullish(),
entity_feature_id: z.string().nullish(),
}, z.enum(ProductItemInterval).nullish())
.meta({
description:
"The reset or billing interval of the product item. If null, feature will have no reset date, and if there's a price, it will be billed one-off.",
}),
interval_count: z.number().nullish().meta({
description: "Interval count of the feature.",
}),
entity_feature_id: z.string().nullish().meta({
description:
"The feature ID of the entity (like seats) to track sub-balances for.",
}),
// Price config
usage_model: z.nativeEnum(UsageModel).nullish(),
price: z.number().nullish(),
tiers: z.array(PriceTierSchema).nullish(),
billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units)
usage_limit: z.number().nullish(),
usage_model: z.enum(UsageModel).nullish().meta({
description:
"Whether the feature should be prepaid upfront or billed for how much they use end of billing period.",
}),
price: z.number().nullish().meta({
description:
"The price of the product item. Should be null if tiered pricing is set.",
}),
tiers: z.array(PriceTierSchema).nullish().meta({
description:
"Tiered pricing for the product item. Not applicable for fixed price items.",
}),
billing_units: z.number().nullish().meta({
description:
"The billing units of the product item (eg $1 for 30 credits).",
}),
// Others
// carry_over_usage: z.boolean().nullish(),
reset_usage_when_enabled: z.boolean().nullish(),
config: ProductItemConfigSchema.nullish(),
reset_usage_when_enabled: z.boolean().nullish().meta({
description:
"Whether the usage should be reset when the product is enabled.",
}),
display: z
.object({
primary_text: z.string(),
secondary_text: z.string().nullish(),
})
.nullish(),
.nullish()
.meta({
internal: true,
}),
// Hidden from users for now.
usage_limit: z.number().nullish().meta({
internal: true,
}),
config: ProductItemConfigSchema.nullish().meta({
internal: true,
}),
// Stored in backend
created_at: z.number().nullish(),
entitlement_id: z.string().nullish(),
price_id: z.string().nullish(),
price_config: z.any().nullish(),
created_at: z.number().nullish().meta({
internal: true,
}),
entitlement_id: z.string().nullish().meta({
internal: true,
}),
price_id: z.string().nullish().meta({
internal: true,
}),
price_config: z.any().nullish().meta({
internal: true,
}),
});
export const LimitedItemSchema = ProductItemSchema.extend({