Files
cfw-autumn/shared/api/products/items/apiPlanItemV1.ts
2026-02-24 18:30:36 +00:00

211 lines
6.2 KiB
TypeScript

import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0.js";
import { BillingMethod } from "@api/products/components/billingMethod.js";
import { DisplaySchema } from "@api/products/components/display.js";
import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType.js";
import { BillingInterval } from "@models/productModels/intervals/billingInterval.js";
import { ResetInterval } from "@models/productModels/intervals/resetInterval.js";
import {
TierBehavior,
UsageTierSchema,
} from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import {
OnDecrease,
OnIncrease,
} from "@models/productV2Models/productItemModels/productItemEnums";
import { z } from "zod/v4";
export const API_PLAN_ITEM_USAGE_BASED_EXAMPLE = {
featureId: "messages",
included: 100,
unlimited: false,
reset: {
interval: "month",
},
price: {
amount: 0.5,
interval: "month",
billingUnits: 100,
billingMethod: "usage_based",
maxPurchase: null,
},
display: {
primaryText: "100 messages",
secondaryText: "then $0.5 per 100 messages",
},
};
export const API_PLAN_ITEM_PREPAID_EXAMPLE = {
featureId: "users",
included: 0,
unlimited: false,
reset: null,
price: {
amount: 10,
interval: "month",
billingUnits: 1,
billingMethod: "prepaid",
maxPurchase: null,
},
display: {
primaryText: "$10 per Users",
},
};
export const ApiPlanItemV1Schema = z
.object({
feature_id: z.string().meta({
description: "The ID of the feature this item configures.",
}),
feature: ApiFeatureV0Schema.optional().meta({
description: "The full feature object if expanded.",
}),
included: z.number().meta({
description:
"Number of free units included. For consumable features, balance resets to this number each interval.",
}),
unlimited: z.boolean().meta({
description: "Whether the customer has unlimited access to this feature.",
}),
reset: z
.object({
interval: z.enum(ResetInterval).meta({
description:
"The interval at which the feature balance resets (e.g. 'month', 'year'). For consumable features, usage resets to 0 and included units are restored.",
}),
interval_count: z.number().optional().meta({
description: "Number of intervals between resets. Defaults to 1.",
}),
})
.nullable()
.meta({
description:
"Reset configuration for consumable features. Null for non-consumable features like seats where usage persists across billing cycles.",
}),
price: z
.object({
amount: z.number().optional().meta({
description:
"Price per billing_units after included usage is consumed. Mutually exclusive with tiers.",
}),
tiers: z.array(UsageTierSchema).optional().meta({
description:
"Tiered pricing configuration. Each tier's 'up_to' does NOT include the included amount. Either 'tiers' or 'amount' is required.",
}),
tier_behavior: z.enum(TierBehavior).optional(),
interval: z.enum(BillingInterval).meta({
description:
"Billing interval for this price. For consumable features, should match reset.interval.",
}),
interval_count: z.number().optional().meta({
description: "Number of intervals per billing cycle. Defaults to 1.",
}),
billing_units: z.number().meta({
description:
"Number of units per price increment. Usage is rounded UP to the nearest billing_units when billed (e.g. billing_units=100 means 101 usage rounds to 200).",
}),
billing_method: z.enum(BillingMethod).meta({
description:
"'prepaid' for features like seats where customers pay upfront, 'usage_based' for pay-as-you-go after included usage.",
}),
max_purchase: z.number().nullable().meta({
description:
"Maximum units a customer can purchase beyond included. E.g. if included=100 and max_purchase=300, customer can use up to 400 total before usage is capped. Null for no limit.",
}),
})
.nullable()
.meta({
description:
"Pricing configuration for usage beyond included units. Null if feature is entirely free.",
}),
display: DisplaySchema.optional().meta({
description: "Display text for showing this item in pricing pages.",
}),
rollover: z
.object({
max: z.number().nullable().meta({
description: "Maximum rollover units. Null for unlimited rollover.",
}),
expiry_duration_type: z.enum(RolloverExpiryDurationType).meta({
description: "When rolled over units expire.",
}),
expiry_duration_length: z.number().optional().meta({
description: "Number of periods before expiry.",
}),
})
.optional()
.meta({
description:
"Rollover configuration for unused units. If set, unused included units roll over to the next period.",
}),
proration: z
.object({
on_increase: z.enum(OnIncrease).optional().meta({
description:
"How to handle billing when quantity increases mid-cycle (prepaid features only).",
}),
on_decrease: z.enum(OnDecrease).optional().meta({
description:
"How to handle credits when quantity decreases mid-cycle (prepaid features only).",
}),
})
.optional()
.meta({
internal: true,
}),
entity_feature_id: z.string().optional().meta({
internal: true,
}),
})
.check((ctx) => {
const resetInterval = ctx.value.reset?.interval;
const priceInterval = ctx.value.price?.interval;
if (
resetInterval &&
priceInterval &&
String(resetInterval) !== String(priceInterval)
) {
ctx.issues.push({
code: "custom",
message: "either pass in reset.interval, or price.interval, not both.",
input: ctx.value,
});
}
if (
ctx.value !== undefined &&
ctx.value.price !== undefined &&
ctx.value.price !== null
) {
if (
ctx.value.price.amount &&
ctx.value.price.tiers &&
ctx.value.price.tiers.length > 0
) {
ctx.issues.push({
code: "custom",
message: "Price amount and tiers are mutually exclusive.",
input: ctx.value,
});
}
}
});
export type ApiPlanItemV1 = z.infer<typeof ApiPlanItemV1Schema>;
export const ApiPlanItemV1WithMeta = ApiPlanItemV1Schema.meta({
id: "PlanItem",
description:
"Configuration for a feature within a plan, defining included units, pricing, and reset behavior.",
example: API_PLAN_ITEM_USAGE_BASED_EXAMPLE,
});