versioning api plan

This commit is contained in:
John Yeo
2026-02-09 08:15:46 -08:00
parent 3d83472397
commit c6d9eb61e1
89 changed files with 1094 additions and 860 deletions

View File

@@ -10,6 +10,7 @@ BUN_PARALLEL_V2 \
'integration/billing/migrations' \
'integration/billing/cron' \
'integration/crud/customers' \
'integration/crud/plans' \
'integration/billing/attach' \

View File

@@ -580,6 +580,11 @@ export class AutumnInt {
return data;
},
list: async <T = any[]>(): Promise<{ list: T }> => {
const data = await this.get(`/products`);
return data as { list: T };
},
create: async (product: any) => {
const data = await this.post(`/products`, product);
return data;

View File

@@ -1,8 +1,8 @@
import {
AffectedResource,
type ApiCustomer,
type ApiEntityV1,
type ApiPlan,
type ApiCustomerV5,
type ApiEntityV2,
type ApiPlanV1,
ApiVersion,
ApiVersionClass,
type AppEnv,
@@ -150,7 +150,7 @@ export const handleProductsUpdated = async ({
});
const versionedCustomer = applyResponseVersionChanges<
ApiCustomer,
ApiCustomerV5,
CustomerLegacyData
>({
input: apiCustomer,
@@ -165,7 +165,7 @@ export const handleProductsUpdated = async ({
features,
});
const versionedPlan = applyResponseVersionChanges<ApiPlan, PlanLegacyData>({
const versionedPlan = applyResponseVersionChanges<ApiPlanV1, PlanLegacyData>({
input: apiPlan,
targetVersion: ctx.apiVersion,
resource: AffectedResource.Product,
@@ -183,7 +183,7 @@ export const handleProductsUpdated = async ({
fullCus,
});
entity = applyResponseVersionChanges<ApiEntityV1, EntityLegacyData>({
entity = applyResponseVersionChanges<ApiEntityV2, EntityLegacyData>({
input: apiEntity,
targetVersion: ctx.apiVersion,
resource: AffectedResource.Entity,

View File

@@ -1,6 +1,6 @@
import {
type ApiCustomer,
type ApiEntityV1,
type ApiCustomerV5,
type ApiEntityV2,
type CheckParams,
type CustomerLegacyData,
type Feature,
@@ -43,7 +43,7 @@ const getFeatureToUse = ({
}: {
creditSystems: Feature[];
feature: Feature;
apiEntity: ApiCustomer | ApiEntityV1;
apiEntity: ApiCustomerV5 | ApiEntityV2;
requiredBalance: number;
}) => {
// 1. If there's a credit system & cusEnts for that credit system -> return credit system
@@ -104,7 +104,7 @@ export const getCheckData = async ({
throw new FeatureNotFoundError({ featureId: feature_id });
}
let apiEntity: ApiCustomer | ApiEntityV1 | undefined;
let apiEntity: ApiCustomerV5 | ApiEntityV2 | undefined;
let legacyData: CustomerLegacyData | undefined;
const start = performance.now();
const fullCustomer = await getOrCreateCachedFullCustomer({

View File

@@ -1,16 +1,4 @@
import {
type CheckoutChange,
CusExpand,
cusProductToProduct,
type FullCusProduct,
type FullProduct,
isPrepaidPrice,
orgToCurrency,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { cusProductToBalances } from "@/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.js";
import { getPlanResponse } from "@/internal/products/productUtils/productResponseUtils/getPlanResponse.js";
import type { AttachParams } from "../../customers/cusProducts/AttachParams.js";
import { type FullCusProduct, isPrepaidPrice } from "@autumn/shared";
/**
* Convert cusProduct.options to feature_quantities with actual quantities
@@ -48,123 +36,3 @@ function cusProductToFeatureQuantities({
};
});
}
/**
* Build incoming change from the new product being attached
*/
async function buildIncomingChange({
ctx,
attachParams,
newProduct,
}: {
ctx: AutumnContext;
attachParams: AttachParams;
newProduct: FullProduct;
}): Promise<CheckoutChange> {
const currency = orgToCurrency({ org: ctx.org });
const plan = await getPlanResponse({
product: newProduct,
features: ctx.features,
fullCus: attachParams.customer,
currency,
expand: [CusExpand.PlanFeaturesFeature],
});
// Build feature quantities from attach options
const featureQuantities = attachParams.optionsList.map((option) => ({
feature_id: option.feature_id,
quantity: option.quantity,
}));
return {
plan,
feature_quantities: featureQuantities,
balances: {},
};
}
/**
* Build outgoing change from the current product being replaced
*/
async function buildOutgoingChange({
ctx,
attachParams,
curCusProduct,
}: {
ctx: AutumnContext;
attachParams: AttachParams;
curCusProduct: FullCusProduct;
}): Promise<CheckoutChange> {
const currency = orgToCurrency({ org: ctx.org });
const fullProduct = cusProductToProduct({ cusProduct: curCusProduct });
const plan = await getPlanResponse({
product: fullProduct,
features: ctx.features,
fullCus: attachParams.customer,
currency,
expand: [CusExpand.PlanFeaturesFeature],
});
const balances = cusProductToBalances({
ctx,
cusProduct: curCusProduct,
fullCustomer: attachParams.customer,
});
const featureQuantities = cusProductToFeatureQuantities({
cusProduct: curCusProduct,
});
return {
plan,
feature_quantities: featureQuantities,
balances,
};
}
/**
* Convert attach params to incoming and outgoing CheckoutChange arrays.
* Incoming = product being attached, Outgoing = product being replaced (if any).
*/
export const attachParamsToChanges = async ({
ctx,
attachParams,
curCusProduct,
}: {
ctx: AutumnContext;
attachParams: AttachParams;
curCusProduct?: FullCusProduct;
}): Promise<{ incoming: CheckoutChange[]; outgoing: CheckoutChange[] }> => {
const incoming: CheckoutChange[] = [];
const outgoing: CheckoutChange[] = [];
// Build new product from attach params
const newProduct: FullProduct = {
...attachParams.products[0],
prices: attachParams.prices,
entitlements: attachParams.entitlements,
free_trial: attachParams.freeTrial,
};
// Always add incoming (the new product being attached)
const incomingChange = await buildIncomingChange({
ctx,
attachParams,
newProduct,
});
incoming.push(incomingChange);
// Add outgoing if there's a current product being replaced
if (curCusProduct) {
const outgoingChange = await buildOutgoingChange({
ctx,
attachParams,
curCusProduct,
});
outgoing.push(outgoingChange);
}
return { incoming, outgoing };
};

View File

@@ -15,7 +15,6 @@ import { getNewProductPreview } from "@/internal/customers/attach/handleAttachPr
import { getUpgradeProductPreview } from "@/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv";
import { attachParamsToChanges } from "./attachParamsToChanges.js";
export const attachParamsToPreview = async ({
ctx,
@@ -102,13 +101,6 @@ export const attachParamsToPreview = async ({
const { curScheduledProduct } = attachParamToCusProducts({ attachParams });
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
// Compute incoming/outgoing changes for the UI
const { incoming, outgoing } = await attachParamsToChanges({
ctx,
attachParams,
curCusProduct,
});
return {
branch,
func,
@@ -119,7 +111,5 @@ export const attachParamsToPreview = async ({
})
: null,
scheduled_product: curScheduledProduct,
incoming,
outgoing,
};
};

View File

@@ -1,10 +1,8 @@
import {
addToExpand,
type BillingContext,
type BillingPeriod,
type BillingPlan,
type CheckoutChange,
CusExpand,
CusProductStatus,
type FullCusProduct,
isPrepaidPrice,
@@ -12,7 +10,7 @@ import {
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { cusProductToBalances } from "@/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.js";
import { getApiSubscriptionForCheckout } from "./getApiSubscriptionForCheckout.js";
import { getApiSubscription } from "@/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.js";
/**
* Convert cusProduct.options to feature_quantities with actual quantities
@@ -87,19 +85,16 @@ export const billingPlanToChanges = async ({
const outgoing: CheckoutChange[] = [];
const { autumn } = billingPlan;
const { fullCustomer } = billingContext;
const ctxWithExpand = addToExpand({
ctx,
add: [CusExpand.SubscriptionsPlan],
});
const lineItems = autumn.lineItems ?? [];
// 1. Products being added (incoming)
for (const cusProduct of autumn.insertCustomerProducts) {
const subscription = await getApiSubscriptionForCheckout({
ctx: ctxWithExpand,
const { data: subscription } = await getApiSubscription({
ctx,
cusProduct,
billingContext,
fullCus: fullCustomer,
expandParams: { plan: true },
});
const balances = cusProductToBalances({
@@ -132,10 +127,11 @@ export const billingPlanToChanges = async ({
updates.ended_at ||
updates.status === CusProductStatus.Expired
) {
const subscription = await getApiSubscriptionForCheckout({
const { data: subscription } = await getApiSubscription({
ctx,
cusProduct: customerProduct,
billingContext,
fullCus: fullCustomer,
expandParams: { plan: true },
});
const balances = cusProductToBalances({
@@ -165,10 +161,11 @@ export const billingPlanToChanges = async ({
if (autumn.deleteCustomerProduct) {
const cusProduct = autumn.deleteCustomerProduct;
const subscription = await getApiSubscriptionForCheckout({
const { data: subscription } = await getApiSubscription({
ctx,
cusProduct,
billingContext,
fullCus: fullCustomer,
expandParams: { plan: true },
});
const balances = cusProductToBalances({

View File

@@ -1,84 +0,0 @@
import {
type BillingContext,
type CheckoutSubscription,
CusExpand,
CusProductStatus,
cusProductToPlanStatus,
cusProductToProduct,
type FullCusProduct,
isCustomerProductTrialing,
orgToCurrency,
secondsToMs,
} from "@autumn/shared";
import {
getEarliestPeriodStart,
getLatestPeriodEnd,
} from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getPlanResponse } from "@/internal/products/productUtils/productResponseUtils/getPlanResponse.js";
/**
* Build an ApiSubscription with plan always included (for checkout display).
* Unlike getApiSubscription which uses ctx.expand, this always includes the plan.
*/
export const getApiSubscriptionForCheckout = async ({
ctx,
cusProduct,
billingContext,
}: {
ctx: AutumnContext;
cusProduct: FullCusProduct;
billingContext: BillingContext;
}): Promise<CheckoutSubscription> => {
const fullProduct = cusProductToProduct({ cusProduct });
const { fullCustomer, stripeSubscription } = billingContext;
const currency = orgToCurrency({ org: ctx.org });
// Always get plan for checkout (with features expanded for display)
const plan = await getPlanResponse({
product: fullProduct,
features: ctx.features,
fullCus: fullCustomer,
currency,
expand: [CusExpand.PlanFeaturesFeature],
});
const status = cusProductToPlanStatus({ status: cusProduct.status });
// Get subscription period from Stripe subscription if available
let periodStart: number | null = null;
let periodEnd: number | null = null;
if (stripeSubscription) {
periodStart =
secondsToMs(getEarliestPeriodStart({ sub: stripeSubscription })) ?? null;
periodEnd =
secondsToMs(getLatestPeriodEnd({ sub: stripeSubscription })) ?? null;
} else if (
cusProduct.trial_ends_at &&
cusProduct.trial_ends_at > Date.now()
) {
periodStart = cusProduct.starts_at;
periodEnd = cusProduct.trial_ends_at;
}
return {
plan,
plan_id: fullProduct.id,
add_on: fullProduct.is_add_on,
default: fullProduct.is_default,
status,
past_due: cusProduct.status === CusProductStatus.PastDue,
canceled_at: cusProduct.canceled_at || null,
expires_at: cusProduct.ended_at || null,
trial_ends_at: isCustomerProductTrialing(cusProduct)
? (cusProduct.trial_ends_at ?? null)
: null,
started_at: cusProduct.starts_at,
quantity: cusProduct.quantity,
current_period_start: periodStart,
current_period_end: periodEnd,
};
};

View File

@@ -7,9 +7,9 @@
import {
AffectedResource,
type ApiCustomer,
type ApiEntityV1,
type ApiPlan,
type ApiCustomerV5,
type ApiEntityV2,
type ApiPlanV1,
ApiVersion,
ApiVersionClass,
addToExpand,
@@ -94,7 +94,7 @@ export const sendProductsUpdated = async ({
});
const versionedCustomer = applyResponseVersionChanges<
ApiCustomer,
ApiCustomerV5,
CustomerLegacyData
>({
input: apiCustomer,
@@ -109,7 +109,7 @@ export const sendProductsUpdated = async ({
features,
});
const versionedPlan = applyResponseVersionChanges<ApiPlan, PlanLegacyData>({
const versionedPlan = applyResponseVersionChanges<ApiPlanV1, PlanLegacyData>({
input: apiPlan,
targetVersion: ctx.apiVersion,
resource: AffectedResource.Product,
@@ -127,7 +127,7 @@ export const sendProductsUpdated = async ({
fullCus: fullCustomer,
});
entity = applyResponseVersionChanges<ApiEntityV1, EntityLegacyData>({
entity = applyResponseVersionChanges<ApiEntityV2, EntityLegacyData>({
input: apiEntity,
targetVersion: ctx.apiVersion,
resource: AffectedResource.Entity,

View File

@@ -1,6 +1,6 @@
import {
AffectedResource,
type ApiCustomer,
type ApiCustomerV5,
applyResponseVersionChanges,
CusExpand,
type CustomerLegacyData,
@@ -21,7 +21,7 @@ export const getApiCustomer = async ({
ctx: RequestContext;
fullCustomer: FullCustomer;
withAutumnId?: boolean;
}): Promise<ApiCustomer> => {
}): Promise<ApiCustomerV5> => {
// Get base ApiCustomer (subscriptions, balances, invoices)
const { apiCustomer: baseCustomer, legacyData: customerLegacyData } =
await getApiCustomerBase({
@@ -31,7 +31,7 @@ export const getApiCustomer = async ({
});
// Clean base customer (remove entities from base, handle expand)
const cleanedBaseCustomer: ApiCustomer = {
const cleanedBaseCustomer: ApiCustomerV5 = {
...baseCustomer,
entities: undefined,
autumn_id: withAutumnId ? baseCustomer.autumn_id : undefined,
@@ -47,13 +47,13 @@ export const getApiCustomer = async ({
fullCus: fullCustomer,
});
const apiCustomer: ApiCustomer = {
const apiCustomer: ApiCustomerV5 = {
...cleanedBaseCustomer,
...apiCustomerExpand,
};
// Apply version transformations based on API version
return applyResponseVersionChanges<ApiCustomer, CustomerLegacyData>({
return applyResponseVersionChanges<ApiCustomerV5, CustomerLegacyData>({
input: apiCustomer,
legacyData: customerLegacyData,
targetVersion: ctx.apiVersion,

View File

@@ -1,6 +1,6 @@
import {
type ApiCustomer,
ApiCustomerSchema,
type ApiCustomerV5,
ApiCustomerV5Schema,
CusExpand,
type CustomerLegacyData,
type FullCustomer,
@@ -24,7 +24,7 @@ export const getApiCustomerBase = async ({
ctx: RequestContext;
fullCus: FullCustomer;
withAutumnId?: boolean;
}): Promise<{ apiCustomer: ApiCustomer; legacyData: CustomerLegacyData }> => {
}): Promise<{ apiCustomer: ApiCustomerV5; legacyData: CustomerLegacyData }> => {
const { data: apiBalances, legacyData: cusFeatureLegacyData } =
await getApiBalances({
ctx,
@@ -37,7 +37,7 @@ export const getApiCustomerBase = async ({
fullCus,
});
const apiCustomer = ApiCustomerSchema.extend({
const apiCustomer = ApiCustomerV5Schema.extend({
autumn_id: z.string().optional(),
}).parse({
autumn_id: withAutumnId ? fullCus.internal_id : undefined,
@@ -50,14 +50,9 @@ export const getApiCustomerBase = async ({
stripe_id: fullCus.processor?.id || null,
env: fullCus.env,
metadata: fullCus.metadata,
// subscriptions: apiSubscriptions,
subscriptions: apiSubscriptions.filter((s) => s.status === "active"),
scheduled_subscriptions: apiSubscriptions.filter(
(s) => s.status === "scheduled",
),
metadata: fullCus.metadata ?? {},
subscriptions: apiSubscriptions,
balances: apiBalances,
send_email_receipts: fullCus.send_email_receipts ?? false,
@@ -67,7 +62,7 @@ export const getApiCustomerBase = async ({
invoices: fullCus.invoices,
})
: undefined,
});
} satisfies ApiCustomerV5);
return {
apiCustomer,

View File

@@ -1,5 +1,7 @@
import {
ApiSubscriptionSchema,
type ApiPlanV1,
type ApiSubscriptionV1,
ApiSubscriptionV1Schema,
CusExpand,
type CusProductLegacyData,
CusProductStatus,
@@ -14,15 +16,29 @@ import {
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
import { getPlanResponse } from "@/internal/products/productUtils/productResponseUtils/getPlanResponse.js";
export const getApiSubscription = async ({
type SubscriptionExpandParams = { plan?: boolean };
type ApiSubscriptionResult<T extends SubscriptionExpandParams> = {
data: T["plan"] extends true
? ApiSubscriptionV1 & { plan: ApiPlanV1 }
: ApiSubscriptionV1;
legacyData: CusProductLegacyData;
};
export const getApiSubscription = async <
// biome-ignore lint/complexity/noBannedTypes: required for type inference
T extends SubscriptionExpandParams = {},
>({
ctx,
fullCus,
cusProduct,
expandParams,
}: {
ctx: RequestContext;
fullCus: FullCustomer;
cusProduct: FullCusProduct;
}) => {
expandParams?: T;
}): Promise<ApiSubscriptionResult<T>> => {
const trialing =
cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now();
@@ -62,9 +78,10 @@ export const getApiSubscription = async ({
const status = cusProductToPlanStatus({ status: cusProduct.status });
// Check if we should expand the plan object
// Use expandParams.plan if provided, otherwise fall back to ctx.expand
const shouldExpandPlan =
status === "scheduled"
expandParams?.plan ??
(status === "scheduled"
? expandIncludes({
expand: ctx.expand,
includes: [CusExpand.ScheduledSubscriptionsPlan],
@@ -72,7 +89,7 @@ export const getApiSubscription = async ({
: expandIncludes({
expand: ctx.expand,
includes: [CusExpand.SubscriptionsPlan],
});
}));
const apiPlan = shouldExpandPlan
? await getPlanResponse({
@@ -81,12 +98,12 @@ export const getApiSubscription = async ({
})
: undefined;
const apiSubscription = ApiSubscriptionSchema.parse({
const apiSubscription = ApiSubscriptionV1Schema.parse({
plan: apiPlan,
plan_id: fullProduct.id,
add_on: fullProduct.is_add_on,
default: fullProduct.is_default,
auto_enable: fullProduct.is_default,
status,
past_due: cusProduct.status === CusProductStatus.PastDue,
@@ -94,25 +111,19 @@ export const getApiSubscription = async ({
expires_at: cusProduct.ended_at || null,
trial_ends_at: isCustomerProductTrialing(cusProduct)
? cusProduct.trial_ends_at
? (cusProduct.trial_ends_at ?? null)
: null,
started_at: cusProduct.starts_at,
quantity: cusProduct.quantity,
current_period_start: stripeSubData?.current_period_start || null,
current_period_end: stripeSubData?.current_period_end || null,
feature_quantities: cusProduct.options.map((option) => ({
feature_id: option.feature_id,
quantity: option.quantity,
upcoming_quantity: option.upcoming_quantity,
})),
});
} satisfies ApiSubscriptionV1);
return {
data: apiSubscription,
legacyData: {
subscription_id: subId || undefined,
options: cusProduct.options,
// features: ctx.features,
} satisfies CusProductLegacyData,
};
} as ApiSubscriptionResult<T>;
};

View File

@@ -1,6 +1,6 @@
import {
ACTIVE_STATUSES,
type ApiSubscription,
type ApiSubscriptionV1,
type CusProductLegacyData,
type CusProductStatus,
type FullCustomer,
@@ -11,16 +11,16 @@ import { getApiSubscription } from "./getApiSubscription.js";
const mergeSubscriptionsResponses = ({
subscriptions,
}: {
subscriptions: ApiSubscription[];
subscriptions: ApiSubscriptionV1[];
}) => {
const getPlanKey = (cp: ApiSubscription) => {
const getPlanKey = (cp: ApiSubscriptionV1) => {
const status = ACTIVE_STATUSES.includes(cp.status as CusProductStatus)
? "active"
: cp.status;
return `${cp.plan_id}:${status}`;
};
const record: Record<string, any> = {};
const record: Record<string, ApiSubscriptionV1> = {};
for (const curr of subscriptions) {
const key = getPlanKey(curr);
@@ -51,7 +51,8 @@ export const getApiSubscriptions = async ({
fullCus: FullCustomer;
}) => {
// Process full subscriptions
const apiSubs: ApiSubscription[] = [];
const apiSubs: ApiSubscriptionV1[] = [];
// const apiPurchases: ApiPurchaseV0[] = [];
const cusProducts = fullCus.customer_products;
@@ -63,6 +64,8 @@ export const getApiSubscriptions = async ({
fullCus,
});
apiSubs.push(processed.data);
apiSubs.push(processed.data);
legacyData[processed.data.plan_id] = processed.legacyData;
}

View File

@@ -1,6 +1,6 @@
import {
AffectedResource,
type ApiEntityV1,
type ApiEntityV2,
applyResponseVersionChanges,
type EntityLegacyData,
EntityNotFoundError,
@@ -26,7 +26,7 @@ export const getApiEntity = async ({
entityId: string;
fullCus?: FullCustomer;
withAutumnId?: boolean;
}): Promise<ApiEntityV1> => {
}): Promise<ApiEntityV2> => {
const fullCustomer =
fullCus ??
(await getOrSetCachedFullCustomer({
@@ -69,7 +69,7 @@ export const getApiEntity = async ({
...apiEntityExpand,
};
return applyResponseVersionChanges<ApiEntityV1, EntityLegacyData>({
return applyResponseVersionChanges<ApiEntityV2, EntityLegacyData>({
input: apiEntity,
legacyData: entityLegacyData,
targetVersion: ctx.apiVersion,

View File

@@ -1,6 +1,6 @@
import {
type ApiEntityV1,
ApiEntityV1Schema,
type ApiEntityV2,
ApiEntityV2Schema,
type Entity,
type EntityLegacyData,
type FullCustomer,
@@ -25,7 +25,7 @@ export const getApiEntityBase = async ({
entity: Entity;
fullCus: FullCustomer;
withAutumnId?: boolean;
}): Promise<{ apiEntity: ApiEntityV1; legacyData: EntityLegacyData }> => {
}): Promise<{ apiEntity: ApiEntityV2; legacyData: EntityLegacyData }> => {
const { org } = ctx;
// Filter customer products for this entity
@@ -55,7 +55,7 @@ export const getApiEntityBase = async ({
fullCus: filteredFullCus,
});
const apiEntity = ApiEntityV1Schema.extend({
const apiEntity = ApiEntityV2Schema.extend({
autumn_id: z.string().optional(),
}).parse({
autumn_id: withAutumnId ? entity.internal_id : undefined,
@@ -66,12 +66,9 @@ export const getApiEntityBase = async ({
created_at: entity.created_at,
env: fullCus.env,
subscriptions: apiSubscriptions.filter((s) => s.status === "active"),
scheduled_subscriptions: apiSubscriptions.filter(
(s) => s.status === "scheduled",
),
subscriptions: apiSubscriptions,
balances: apiBalances,
});
} satisfies ApiEntityV2);
return {
apiEntity,

View File

@@ -1,6 +1,6 @@
import {
AffectedResource,
type ApiPlan,
type ApiPlanV1,
ApiVersion,
apiPlan,
applyResponseVersionChanges,
@@ -147,7 +147,7 @@ export const handleCreatePlan = createRoute({
});
// Apply version transformations for client
const versionedResponse = applyResponseVersionChanges<ApiPlan>({
const versionedResponse = applyResponseVersionChanges<ApiPlanV1>({
input: planResponse,
targetVersion: ctx.apiVersion,
resource: AffectedResource.Product,

View File

@@ -1,6 +1,6 @@
import {
AffectedResource,
type ApiPlan,
type ApiPlanV1,
applyResponseVersionChanges,
ErrCode,
ProductNotFoundError,
@@ -57,7 +57,7 @@ export const handleGetPlan = createRoute({
features,
});
const versionedResponse = applyResponseVersionChanges<ApiPlan>({
const versionedResponse = applyResponseVersionChanges<ApiPlanV1>({
input: planResponse,
targetVersion: apiVersion,
resource: AffectedResource.Product,

View File

@@ -1,6 +1,6 @@
import {
AffectedResource,
type ApiPlan,
type ApiPlanV1,
applyResponseVersionChanges,
ListPlansQuerySchema,
} from "@autumn/shared";
@@ -56,19 +56,12 @@ export const handleListPlans = createRoute({
db,
currency: org.default_currency || undefined,
}),
// getProductResponse({
// product: p,
// features,
// currency: org.default_currency || undefined,
// db,
// fullCus: customer ? customer : undefined,
// }),
);
}
const plansList = await Promise.all(batchResponse);
const res = plansList.map((p) => {
return applyResponseVersionChanges<ApiPlan>({
return applyResponseVersionChanges<ApiPlanV1>({
input: p,
targetVersion: ctx.apiVersion,
resource: AffectedResource.Product,

View File

@@ -1,6 +1,6 @@
import {
AffectedResource,
type ApiPlan,
type ApiPlanV1,
ApiVersion,
ApiVersionClass,
apiPlan,
@@ -236,7 +236,7 @@ export const handleUpdatePlan = createRoute({
features,
});
const versionedResponse = applyResponseVersionChanges<ApiPlan>({
const versionedResponse = applyResponseVersionChanges<ApiPlanV1>({
input: planResponse,
targetVersion: ctx.apiVersion,
resource: AffectedResource.Product,

View File

@@ -1,15 +1,15 @@
import {
type ApiFreeTrialV2,
ApiFreeTrialV2Schema,
type ApiPlan,
ApiPlanSchema,
type ApiPlanV1,
ApiPlanV1Schema,
AttachScenario,
type Feature,
type FullCustomer,
type FullProduct,
getProductItemDisplay,
itemsToPlanFeatures,
itemToBillingInterval,
productItemsToPlanItemsV1,
productV2ToBasePrice,
productV2ToFeatureItems,
sortProductItems,
@@ -70,7 +70,7 @@ const getTrialAvailable = async ({
};
/**
* Convert FullProduct (DB format) to Plan API response format
* Convert FullProduct (DB format) to Plan API response format (V1/latest)
*/
export const getPlanResponse = async ({
product,
@@ -86,7 +86,7 @@ export const getPlanResponse = async ({
db?: DrizzleCli;
currency?: string;
expand?: string[];
}): Promise<ApiPlan> => {
}): Promise<ApiPlanV1> => {
// 1. Convert prices/entitlements to items
const rawItems = mapToProductItems({
prices: product.prices,
@@ -102,7 +102,7 @@ export const getPlanResponse = async ({
// 4. Extract base price using existing helper
const basePriceItem = productV2ToBasePrice({ product: productV2 as any });
const basePrice: ApiPlan["price"] | null = basePriceItem
const basePrice: ApiPlanV1["price"] | null = basePriceItem
? {
amount: basePriceItem.price,
interval: itemToBillingInterval({ item: basePriceItem }),
@@ -125,13 +125,13 @@ export const getPlanResponse = async ({
});
// 6. Convert items to plan features
let planFeatures = itemsToPlanFeatures({
let planItems = productItemsToPlanItemsV1({
items: featureItems,
features,
expand,
});
planFeatures = planFeatures.map((pf) => ({ ...pf, proration: undefined }));
planItems = planItems.map((item) => ({ ...item, proration: undefined }));
// 7. Get attach scenario for customer context
const attachScenario = getAttachScenario({
@@ -152,23 +152,23 @@ export const getPlanResponse = async ({
});
// 9. Build Plan response
return ApiPlanSchema.parse({
return ApiPlanV1Schema.parse({
// Basic fields
id: product.id,
name: product.name || null,
description: product.description || null, // Products don't have descriptions
name: product.name || "",
description: product.description || null,
group: product.group || null,
version: product.version,
// Boolean flags
add_on: product.is_add_on,
default: product.is_default,
auto_enable: product.is_default,
// Price field (optional - only for products with base price)
price: basePrice,
// Features array
features: planFeatures ?? [],
// Items array (V1 uses "items" not "features")
items: planItems ?? [],
// Free trial
free_trial: freeTrial,
@@ -186,5 +186,5 @@ export const getPlanResponse = async ({
scenario: attachScenario,
}
: undefined,
});
} satisfies ApiPlanV1);
};

View File

@@ -1,62 +0,0 @@
import { beforeAll, describe, test } from "bun:test";
import { type ApiProduct, ApiVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import {
constructArrearItem,
constructFeatureItem,
constructPrepaidItem,
} from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const usageFeature = constructArrearItem({
featureId: TestFeature.Words,
includedUsage: 10,
billingUnits: 1,
price: 0.5,
});
const prepaidFeature = constructPrepaidItem({
featureId: TestFeature.Credits,
billingUnits: 150,
price: 10,
});
const pro = constructProduct({
type: "pro",
isDefault: false,
items: [messagesFeature, usageFeature, prepaidFeature],
});
const testCase = "get-plan1";
describe(`${chalk.yellowBright("get-plan1: get plan response v1.2")}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initProductsV0({
ctx,
products: [pro],
prefix: testCase,
});
});
test("should track version 1.2 response", async () => {
const plan = (await autumnV1.products.get(pro.id)) as ApiProduct;
// 1. Check messages product item
const msgesResponseItem = plan.items.find(
(item) => item.feature_id === TestFeature.Messages,
);
console.log(msgesResponseItem);
});
});

View File

@@ -0,0 +1,51 @@
import { expect, test } from "bun:test";
import { type ApiProduct, ApiVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
const testCase = "get-plan-basic";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const wordsItem = items.consumableWords({ includedUsage: 10 });
const creditsItem = items.monthlyCredits({ includedUsage: 10 });
const pro = products.pro({
id: "pro",
items: [messagesItem, wordsItem, creditsItem],
});
test.concurrent(`${chalk.yellowBright("get-plan-basic: get plan response v1.2")}`, async () => {
await initScenario({
setup: [s.products({ list: [pro], prefix: testCase })],
actions: [],
});
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
const productV2 = (await autumnV1.products.get(pro.id)) as ApiProduct;
// Check messages product item
const messagesResponseItem = productV2.items.find(
(item) => item.feature_id === TestFeature.Messages,
);
const wordsResponseItem = productV2.items.find(
(item) => item.feature_id === TestFeature.Words,
);
const creditsResponseItem = productV2.items.find(
(item) => item.feature_id === TestFeature.Credits,
);
const priceItem = productV2.items.find(
(item) => item.type === ("price" as const),
);
expect(messagesResponseItem).toBeDefined();
expect(wordsResponseItem).toBeDefined();
expect(creditsResponseItem).toBeDefined();
expect(priceItem).toBeDefined();
});

View File

@@ -0,0 +1,71 @@
import { expect, test } from "bun:test";
import {
type ApiPlan,
ApiPlanV0Schema,
type ApiPlanV1,
ApiPlanV1Schema,
type ApiProduct,
ApiProductSchema,
ApiVersion,
} from "@autumn/shared";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
const testCase = "list-plans-cross-version";
const creditsItem = items.monthlyCredits({ includedUsage: 500 });
const creditsItemPro = items.monthlyCredits({ includedUsage: 5000 });
const creditsItemPremium = items.monthlyCredits({ includedUsage: 50_000 });
const free = products.base({
id: "free",
isDefault: true,
items: [creditsItem],
});
const pro = products.pro({
id: "pro",
items: [creditsItemPro],
});
const premium = products.premium({
id: "premium",
items: [creditsItemPremium],
});
test.concurrent(`${chalk.yellowBright("list-plans-cross-version: list products cross version")}`, async () => {
await initScenario({
setup: [s.products({ list: [free, pro, premium], prefix: testCase })],
actions: [],
});
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
const autumnV2_0 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV2_1 = new AutumnInt({ version: ApiVersion.V2_1 });
// V2.1 - should return ApiPlanV1 schema (items, auto_enable)
const plansV2_1 = await autumnV2_1.products.list<ApiPlanV1[]>();
for (const plan of plansV2_1.list) {
ApiPlanV1Schema.parse(plan);
}
// V2.0 - should return ApiPlan schema (features, default)
const plansV2_0 = await autumnV2_0.products.list<ApiPlan[]>();
for (const plan of plansV2_0.list) {
ApiPlanV0Schema.parse(plan);
}
// V1.2 - should return ApiProduct schema
const productsV1 = await autumnV1.products.list<ApiProduct[]>();
for (const product of productsV1.list) {
ApiProductSchema.parse(product);
}
// Verify we have the expected products
expect(plansV2_1.list.length).toBeGreaterThanOrEqual(3);
expect(plansV2_0.list.length).toBeGreaterThanOrEqual(3);
expect(productsV1.list.length).toBeGreaterThanOrEqual(3);
});

View File

@@ -1,5 +1,5 @@
import { writeFileSync } from "node:fs";
import { ApiPlanItemV0WithMeta } from "@api/products/items/apiPlanItemV0.js";
import { ApiPlanItemV0WithMeta } from "@api/products/items/previousVersions/apiPlanItemV0.js";
import yaml from "yaml";
import { createDocument } from "zod-openapi";
import { CustomerDataSchema } from "../common/customerData.js";

View File

@@ -1,13 +1,13 @@
import { z } from "zod/v4";
import { SuccessResponseSchema } from "../common/commonResponses.js";
import { ApiPlanSchema } from "../products/apiPlan.js";
import {
CreatePlanParamsSchema,
ListPlansQuerySchema,
UpdatePlanParamsSchema,
} from "../products/crud/planOpModels.js";
import { ApiPlanV0Schema } from "../products/previousVersions/apiPlanV0.js";
export const ApiPlanWithMeta = ApiPlanSchema.meta({
export const ApiPlanWithMeta = ApiPlanV0Schema.meta({
id: "Plan",
// examples: [PLAN_EXAMPLE],
});

View File

@@ -1,7 +0,0 @@
import { z } from "zod/v4";
import { CheckoutParamsV1Schema } from "../checkout/checkoutParamsV1";
export const AttachBodyV1Schema = CheckoutParamsV1Schema.extend({
force_checkout: z.boolean().optional(),
});
export type AttachBodyV1 = z.infer<typeof AttachBodyV1Schema>;

View File

@@ -1,41 +0,0 @@
import { z } from "zod/v4";
import { CustomerDataSchema } from "../../common/customerData.js";
import { EntityDataSchema } from "../../common/entityData.js";
import { FeatureQuantitySchema } from "../common/featureQuantities.js";
import { PlanOverrideSchema } from "../common/planOverride.js";
export const ExtCheckoutParamsV1Schema = z
.object({
// Customer / Entity Info
customer_id: z.string(),
plan_id: z.string(),
version: z.number().optional(),
entity_id: z.string().optional(),
customer_data: CustomerDataSchema.optional(),
entity_data: EntityDataSchema.optional(),
feature_quantities: z.array(FeatureQuantitySchema).optional(),
success_url: z.string().optional(),
checkout_session_params: z.record(z.string(), z.any()).optional(),
reward: z.string().or(z.array(z.string())).optional(),
invoice: z.boolean().optional(),
invoice_settings: z.object({
enable_immediately: z.boolean(),
finalize_immediately: z.boolean(),
}),
plan_override: PlanOverrideSchema.optional(),
})
.meta({
description:
"Returns a Stripe Checkout URL for the customer to make a payment, or returns payment confirmation information.",
});
export const CheckoutParamsV1Schema = ExtCheckoutParamsV1Schema.extend({
setup_payment: z.boolean().optional(),
});
export type CheckoutParamsV1 = z.infer<typeof CheckoutParamsV1Schema>;

View File

@@ -1,26 +0,0 @@
import { CreatePlanItemParamsV0Schema } from "@api/products/items/crud/createPlanItemV0Params.js";
import { z } from "zod/v4";
import { ApiFreeTrialV2Schema } from "../../models.js";
import { PlanPriceSchema } from "../../products/crud/planOpModels.js";
export const PlanOverrideSchema = z
.object({
price: PlanPriceSchema.optional(),
features: z.array(CreatePlanItemParamsV0Schema).optional(),
free_trial: ApiFreeTrialV2Schema.nullable().optional(),
})
.refine(
(data) => {
if (!data.price && !data.features && !data.free_trial) {
return false;
}
return true;
},
{
message:
"Plan override must contain at least one of price, features, or free_trial",
},
);
type PlanOverride = z.infer<typeof PlanOverrideSchema>;

View File

@@ -1,11 +1,11 @@
// Attach
export * from "./attach/attachBodyV1.js";
export * from "./attach/prevVersions/attachBodyV0.js";
export * from "./attach/prevVersions/attachResponseV1.js";
// Attach V2
export * from "./attachV2/attachParamsV0.js";
// Checkout
export * from "./checkout/checkoutParamsV1.js";
export * from "./checkout/prevVersions/checkoutParamsV0.js";
export * from "./checkout/prevVersions/checkoutResponseV0.js";
// Common

View File

@@ -1,14 +1,18 @@
import { ApiBaseEntitySchema } from "@api/entities/apiBaseEntity.js";
import { ApiCusRewardsSchema } from "@api/others/apiDiscount.js";
import { ApiInvoiceV1Schema } from "@api/others/apiInvoice/apiInvoiceV1.js";
import { AppEnv } from "@models/genModels/genEnums.js";
import { z } from "zod/v4";
import { BaseApiCustomerSchema } from "./baseApiCustomer.js";
import { ApiCusReferralSchema } from "./components/apiCusReferral.js";
import { ApiTrialsUsedV1Schema } from "./components/apiTrialsUsed/apiTrialsUsedV1.js";
import { ApiBalanceSchema } from "./cusFeatures/apiBalance.js";
import { ApiSubscriptionSchema } from "./cusPlans/apiSubscription.js";
export {
type BaseApiCustomer,
BaseApiCustomerSchema,
} from "./baseApiCustomer.js";
export const ApiCusExpandSchema = z.object({
invoices: z.array(ApiInvoiceV1Schema).optional(),
entities: z.array(ApiBaseEntitySchema).optional(),
@@ -18,32 +22,17 @@ export const ApiCusExpandSchema = z.object({
payment_method: z.any().nullish(),
});
export const BaseApiCustomerSchema = z
.object({
autumn_id: z.string().optional().meta({
internal: true,
}),
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string().nullable(),
created_at: z.number(),
fingerprint: z.string().nullable(),
stripe_id: z.string().nullable(),
env: z.enum(AppEnv),
metadata: z.record(z.any(), z.any()),
subscriptions: z.array(ApiSubscriptionSchema),
scheduled_subscriptions: z.array(ApiSubscriptionSchema),
balances: z.record(z.string(), ApiBalanceSchema),
send_email_receipts: z.boolean(),
})
.meta({
id: "BaseCustomer",
});
// V4 base customer - adds V0 subscriptions and balances
export const BaseApiCustomerV4Schema = BaseApiCustomerSchema.extend({
subscriptions: z.array(ApiSubscriptionSchema),
scheduled_subscriptions: z.array(ApiSubscriptionSchema),
balances: z.record(z.string(), ApiBalanceSchema),
});
export const ApiCustomerSchema = BaseApiCustomerSchema.extend(
export const ApiCustomerSchema = BaseApiCustomerV4Schema.extend(
ApiCusExpandSchema.shape,
);
export type ApiCustomer = z.infer<typeof ApiCustomerSchema>;
export type ApiCusExpand = z.infer<typeof ApiCusExpandSchema>;
export type BaseApiCustomer = z.infer<typeof BaseApiCustomerSchema>;
export type BaseApiCustomerV4 = z.infer<typeof BaseApiCustomerV4Schema>;

View File

@@ -0,0 +1,18 @@
import { z } from "zod/v4";
import { ApiCusExpandSchema } from "./apiCustomer.js";
import { BaseApiCustomerSchema } from "./baseApiCustomer.js";
import { ApiBalanceSchema } from "./cusFeatures/apiBalance.js";
import { ApiSubscriptionV1Schema } from "./cusPlans/apiSubscriptionV1.js";
// V5 base customer - uses V1 subscriptions (single array with status field)
export const BaseApiCustomerV5Schema = BaseApiCustomerSchema.extend({
subscriptions: z.array(ApiSubscriptionV1Schema),
balances: z.record(z.string(), ApiBalanceSchema),
});
export const ApiCustomerV5Schema = BaseApiCustomerV5Schema.extend(
ApiCusExpandSchema.shape,
);
export type ApiCustomerV5 = z.infer<typeof ApiCustomerV5Schema>;
export type BaseApiCustomerV5 = z.infer<typeof BaseApiCustomerV5Schema>;

View File

@@ -0,0 +1,19 @@
import { AppEnv } from "@models/genModels/genEnums.js";
import { z } from "zod/v4";
export const BaseApiCustomerSchema = z.object({
autumn_id: z.string().optional().meta({
internal: true,
}),
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string().nullable(),
created_at: z.number(),
fingerprint: z.string().nullable(),
stripe_id: z.string().nullable(),
env: z.enum(AppEnv),
metadata: z.record(z.any(), z.any()),
send_email_receipts: z.boolean(),
});
export type BaseApiCustomer = z.infer<typeof BaseApiCustomerSchema>;

View File

@@ -0,0 +1,49 @@
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import type { z } from "zod/v4";
import { ApiCustomerSchema } from "../apiCustomer.js";
import { ApiCustomerV5Schema } from "../apiCustomerV5.js";
import type { ApiSubscription } from "../cusPlans/apiSubscription.js";
import { apiSubscriptionV1ToV0 } from "../cusPlans/mappers/apiSubscriptionV1ToV0.js";
import { CustomerLegacyDataSchema } from "../customerLegacyData.js";
export const V2_0_CustomerChange = defineVersionChange({
name: "V2_0 Customer Change",
newVersion: ApiVersion.V2_1,
oldVersion: ApiVersion.V2_0,
description: [
"Subscription schema transforms (V1 to V0) - plan field versioning",
],
affectedResources: [AffectedResource.Customer],
newSchema: ApiCustomerV5Schema,
oldSchema: ApiCustomerSchema,
legacyDataSchema: CustomerLegacyDataSchema,
affectsResponse: true,
transformResponse: ({
input,
}: {
input: z.infer<typeof ApiCustomerV5Schema>;
legacyData?: z.infer<typeof CustomerLegacyDataSchema>;
}): z.infer<typeof ApiCustomerSchema> => {
// Transform subscriptions from V1 to V0
const allSubscriptions = input.subscriptions ?? [];
const activeSubscriptionsV0: ApiSubscription[] = allSubscriptions
.filter((sub) => sub.status === "active")
.map((sub) => apiSubscriptionV1ToV0({ input: sub }));
const scheduledSubscriptionsV0: ApiSubscription[] = allSubscriptions
.filter((sub) => sub.status === "scheduled")
.map((sub) => apiSubscriptionV1ToV0({ input: sub }));
return {
...input,
subscriptions: activeSubscriptionsV0,
scheduled_subscriptions: scheduledSubscriptionsV0,
};
},
});

View File

@@ -0,0 +1,4 @@
export * from "./apiCusReferral.js";
export * from "./apiCusUpcomingInvoice.js";
export * from "./apiTrialsUsed/apiTrialsUsedV1.js";
export * from "./apiTrialsUsed/prevVersions/apiTrialsUsedV0.js";

View File

@@ -0,0 +1 @@
export * from "./listCustomersParamsV2.js";

View File

@@ -5,10 +5,10 @@ import {
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import type { EntInterval } from "@models/productModels/intervals/entitlementInterval.js";
import { resetIntvToEntIntv } from "@utils/productV2Utils/productItemUtils/convertProductItem/planItemIntervals.js";
import { Decimal } from "decimal.js";
import type { z } from "zod/v4";
import { FeatureType } from "../../../../models/featureModels/featureEnums.js";
import { resetIntvToEntIntv } from "../../../../utils/planFeatureUtils/planFeatureIntervals.js";
import { sumValues } from "../../../../utils/utils.js";
import type { ApiFeatureV1 } from "../../../features/apiFeatureV1.js";
import {

View File

@@ -0,0 +1,6 @@
export * from "./apiBalance.js";
export * from "./cusFeatureLegacyData.js";
export * from "./previousVersions/apiCusFeatureV0.js";
export * from "./previousVersions/apiCusFeatureV1.js";
export * from "./previousVersions/apiCusFeatureV2.js";
export * from "./previousVersions/apiCusFeatureV3.js";

View File

@@ -1,8 +1,8 @@
import { ApiPlanSchema } from "@api/products/apiPlan.js";
import { ApiPlanV0Schema } from "@api/products/previousVersions/apiPlanV0";
import { z } from "zod/v4";
export const ApiSubscriptionSchema = z.object({
plan: ApiPlanSchema.optional(),
plan: ApiPlanV0Schema.optional(),
plan_id: z.string(),
default: z.boolean(),
@@ -19,14 +19,6 @@ export const ApiSubscriptionSchema = z.object({
current_period_start: z.number().nullable(),
current_period_end: z.number().nullable(),
quantity: z.number(),
// feature_quantities: z.array(
// z.object({
// feature_id: z.string(),
// quantity: z.number(),
// upcoming_quantity: z.number().nullable(),
// }),
// ),
});
export type ApiSubscription = z.infer<typeof ApiSubscriptionSchema>;

View File

@@ -0,0 +1,32 @@
import { ApiPlanV1Schema } from "@api/products/apiPlanV1.js";
import { z } from "zod/v4";
export const ApiSubscriptionV1Schema = z.object({
plan: ApiPlanV1Schema.optional(),
plan_id: z.string(),
auto_enable: z.boolean(),
add_on: z.boolean(),
// Flags / timestamps
status: z.enum(["active", "scheduled", "expired"]),
past_due: z.boolean(),
canceled_at: z.number().nullable(),
expires_at: z.number().nullable(),
trial_ends_at: z.number().nullable(),
started_at: z.number(),
current_period_start: z.number().nullable(),
current_period_end: z.number().nullable(),
quantity: z.number(),
});
export const ApiPurchaseV0Schema = z.object({
plan: ApiPlanV1Schema.optional(),
plan_id: z.string(),
expires_at: z.number().nullable(),
started_at: z.number(),
});
export type ApiSubscriptionV1 = z.infer<typeof ApiSubscriptionV1Schema>;
export type ApiPurchaseV0 = z.infer<typeof ApiPurchaseV0Schema>;

View File

@@ -1,9 +1,4 @@
import type { ApiProductItemV0Schema } from "@api/products/items/previousVersions/apiProductItemV0.js";
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { TierInfinite } from "@models/productV2Models/productItemModels/productItemModels.js";
import {
isFeatureItem,
@@ -11,12 +6,9 @@ import {
} from "@utils/productV2Utils/productItemUtils/getItemType.js";
import { notNullish } from "@utils/utils.js";
import type { z } from "zod/v4";
import {
type CusProductLegacyData,
CusProductLegacyDataSchema,
} from "../cusProductLegacyData.js";
import { ApiCusProductV1Schema } from "../previousVersions/apiCusProductV1.js";
import { ApiCusProductV2Schema } from "../previousVersions/apiCusProductV2.js";
import type { CusProductLegacyData } from "../cusProductLegacyData.js";
import type { ApiCusProductV1Schema } from "../previousVersions/apiCusProductV1.js";
import type { ApiCusProductV2Schema } from "../previousVersions/apiCusProductV2.js";
/**
* Transform product from V2 format to V1 format
@@ -134,17 +126,3 @@ export function transformCusProductV2ToV1({
return v1CusProduct;
}
const V0_2_CusProductChange = defineVersionChange({
newVersion: ApiVersion.V1_1, // Breaking change introduced in V1_1
oldVersion: ApiVersion.V0_2, // Applied when targetVersion <= V0_2
description: ["Customer product response revamped to fit ProductV2 schema"],
affectedResources: [AffectedResource.CusProduct],
newSchema: ApiCusProductV2Schema,
oldSchema: ApiCusProductV1Schema,
legacyDataSchema: CusProductLegacyDataSchema,
// Response: V1.1+ (V2) → V0_2 (V1)
transformResponse: transformCusProductV2ToV1,
});

View File

@@ -0,0 +1,25 @@
import { planV1ToV0 } from "@api/products/mappers/planV1ToV0.js";
import type { ApiSubscription } from "../apiSubscription.js";
import type { ApiSubscriptionV1 } from "../apiSubscriptionV1.js";
export function transformApiSubscriptionV1ToV0({
input,
}: {
input: ApiSubscriptionV1;
}): ApiSubscription {
return {
plan: input.plan ? planV1ToV0(input.plan) : undefined,
plan_id: input.plan_id,
default: input.auto_enable,
add_on: input.add_on,
status: input.status,
past_due: input.past_due,
canceled_at: input.canceled_at,
expires_at: input.expires_at,
trial_ends_at: input.trial_ends_at,
started_at: input.started_at,
current_period_start: input.current_period_start,
current_period_end: input.current_period_end,
quantity: input.quantity,
};
}

View File

@@ -0,0 +1,9 @@
export * from "./apiSubscription.js";
export * from "./apiSubscriptionV1.js";
export * from "./cusProductLegacyData.js";
export * from "./mappers/apiSubscriptionV1ToPurchaseV0.js";
export * from "./mappers/apiSubscriptionV1ToV0.js";
export * from "./previousVersions/apiCusProductV0.js";
export * from "./previousVersions/apiCusProductV1.js";
export * from "./previousVersions/apiCusProductV2.js";
export * from "./previousVersions/apiCusProductV3.js";

View File

@@ -0,0 +1,16 @@
import type { ApiPurchaseV0, ApiSubscriptionV1 } from "../apiSubscriptionV1.js";
export function apiSubscriptionV1ToPurchaseV0({
apiSubscriptionV1,
}: {
apiSubscriptionV1: ApiSubscriptionV1;
}): ApiPurchaseV0 {
const input = apiSubscriptionV1;
return {
plan: input.plan,
plan_id: input.plan_id,
expires_at: input.expires_at,
started_at: input.started_at,
};
}

View File

@@ -0,0 +1,25 @@
import { planV1ToV0 } from "@api/products/mappers/planV1ToV0.js";
import type { ApiSubscription } from "../apiSubscription.js";
import type { ApiSubscriptionV1 } from "../apiSubscriptionV1.js";
export function apiSubscriptionV1ToV0({
input,
}: {
input: ApiSubscriptionV1;
}): ApiSubscription {
return {
plan: input.plan ? planV1ToV0(input.plan) : undefined,
plan_id: input.plan_id,
default: input.auto_enable,
add_on: input.add_on,
status: input.status,
past_due: input.past_due,
canceled_at: input.canceled_at,
expires_at: input.expires_at,
trial_ends_at: input.trial_ends_at,
started_at: input.started_at,
current_period_start: input.current_period_start,
current_period_end: input.current_period_end,
quantity: input.quantity,
};
}

View File

@@ -0,0 +1,16 @@
// Main customer schemas
export * from "./apiCustomer.js";
export * from "./apiCustomerV5.js";
export * from "./baseApiCustomer.js";
// Submodules
export * from "./components/index.js";
export * from "./createCustomerParams.js";
export * from "./crud/index.js";
export * from "./cusFeatures/index.js";
export * from "./cusPlans/index.js";
export * from "./customerLegacyData.js";
export * from "./customerOpModels.js";
export * from "./previousVersions/index.js";
// NOTE: changes/ and requestChanges/ are NOT exported here to avoid circular imports
// Import them directly where needed (e.g., versionChangeRegistry.ts)

View File

@@ -0,0 +1,4 @@
export * from "./apiCustomerV0.js";
export * from "./apiCustomerV1.js";
export * from "./apiCustomerV2.js";
export * from "./apiCustomerV3.js";

View File

@@ -0,0 +1,25 @@
import { z } from "zod/v4";
import { ApiBalanceSchema } from "../customers/cusFeatures/apiBalance.js";
import { ApiSubscriptionV1Schema } from "../customers/cusPlans/apiSubscriptionV1.js";
import { ApiInvoiceV1Schema } from "../others/apiInvoice/apiInvoiceV1.js";
import { ApiBaseEntitySchema } from "./apiBaseEntity.js";
// V2 base entity - uses V1 subscriptions (single array with status field)
export const BaseApiEntityV2Schema = ApiBaseEntitySchema.extend({
subscriptions: z.array(ApiSubscriptionV1Schema),
balances: z.record(z.string(), ApiBalanceSchema),
});
export const ApiEntityExpandSchema = z.object({
invoices: z.array(ApiInvoiceV1Schema).optional().meta({
description:
"Invoices for this entity (only included when expand=invoices)",
}),
});
export const ApiEntityV2Schema = BaseApiEntityV2Schema.extend(
ApiEntityExpandSchema.shape,
);
export type ApiEntityV2 = z.infer<typeof ApiEntityV2Schema>;
export type BaseApiEntityV2 = z.infer<typeof BaseApiEntityV2Schema>;

View File

@@ -0,0 +1,63 @@
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import type { z } from "zod/v4";
import type { ApiSubscription } from "../../customers/cusPlans/apiSubscription.js";
import { apiSubscriptionV1ToV0 } from "../../customers/cusPlans/mappers/apiSubscriptionV1ToV0.js";
import { ApiEntityV1Schema } from "../apiEntity.js";
import { ApiEntityV2Schema } from "../apiEntityV2.js";
import { EntityLegacyDataSchema } from "../entityLegacyData.js";
/**
* V2.0_EntityChange: Transforms entity response TO V1 format from V2 format
*
* Applied when: targetVersion <= V2.0 (request is for V2.0 or older)
*
* Breaking changes introduced in V2.1:
*
* 1. Subscription schema changes:
* - V2.1+: Single "subscriptions" array with ApiSubscriptionV1 (auto_enable, ApiPlanV1)
* - V2.0: Split arrays "subscriptions" + "scheduled_subscriptions" with ApiSubscription (default, ApiPlanV0)
*
* Input: ApiEntityV2 (V2.1+ format)
* Output: ApiEntityV1 (V2.0 format)
*/
export const V2_0_EntityChange = defineVersionChange({
name: "V2_0 Entity Change",
newVersion: ApiVersion.V2_1,
oldVersion: ApiVersion.V2_0,
description: [
"Subscription schema transforms (V1 to V0) - plan field versioning",
],
affectedResources: [AffectedResource.Entity],
newSchema: ApiEntityV2Schema,
oldSchema: ApiEntityV1Schema,
legacyDataSchema: EntityLegacyDataSchema,
affectsResponse: true,
transformResponse: ({
input,
}: {
input: z.infer<typeof ApiEntityV2Schema>;
legacyData?: z.infer<typeof EntityLegacyDataSchema>;
}): z.infer<typeof ApiEntityV1Schema> => {
// Transform subscriptions from V1 to V0
const allSubscriptions = input.subscriptions ?? [];
const activeSubscriptionsV0: ApiSubscription[] = allSubscriptions
.filter((sub) => sub.status === "active")
.map((sub) => apiSubscriptionV1ToV0({ input: sub }));
const scheduledSubscriptionsV0: ApiSubscription[] = allSubscriptions
.filter((sub) => sub.status === "scheduled")
.map((sub) => apiSubscriptionV1ToV0({ input: sub }));
return {
...input,
subscriptions: activeSubscriptionsV0,
scheduled_subscriptions: scheduledSubscriptionsV0,
};
},
});

View File

@@ -2,41 +2,19 @@
// NOTE: coreOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation
export * from "./core/coreOpModels.js";
// Helpers
export * from "./utils/openApiHelpers.js";
export * from "./utils/zodToJSDoc.js";
// Customers
export * from "./customers/apiCustomer.js";
export * from "./customers/components/apiCusReferral.js";
export * from "./customers/components/apiCusUpcomingInvoice.js";
export * from "./customers/createCustomerParams.js";
export * from "./customers/cusFeatures/apiBalance.js";
export * from "./customers/cusFeatures/previousVersions/apiCusFeatureV0.js";
export * from "./customers/cusFeatures/previousVersions/apiCusFeatureV1.js";
export * from "./customers/cusFeatures/previousVersions/apiCusFeatureV2.js";
export * from "./customers/cusFeatures/previousVersions/apiCusFeatureV3.js";
export * from "./customers/cusPlans/apiSubscription.js";
export * from "./customers/cusPlans/cusProductLegacyData.js";
export * from "./customers/customerLegacyData.js";
export * from "./customers/customerOpModels.js";
export * from "./customers/previousVersions/apiCustomerV2.js";
export * from "./customers/previousVersions/apiCustomerV3.js";
export * from "./customers/index.js";
// Entities
export * from "./entities/apiEntity.js";
export * from "./entities/apiEntityV2.js";
export * from "./entities/entityLegacyData.js";
export * from "./entities/entityOpModels.js";
export * from "./entities/prevVersions/apiEntityV0.js";
export * from "./errors/classes/featureErrClasses.js";
export * from "./errors/codes/featureErrCodes.js";
// Features
export * from "./features/prevVersions/apiFeatureV0.js";
export * from "./features/prevVersions/featureV0OpModels.js";
// Others
export * from "./others/apiDiscount.js";
export * from "./others/apiInvoice/apiInvoiceV1.js";
@@ -45,6 +23,9 @@ export * from "./products/index.js";
// Referrals
export * from "./referrals/apiReferralCode.js";
export * from "./referrals/referralOpModels.js";
// Helpers
export * from "./utils/openApiHelpers.js";
export * from "./utils/zodToJSDoc.js";
// NOTE: productsOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation
@@ -70,9 +51,6 @@ export * from "./billing/index.js";
export * from "./common/customerData.js";
export * from "./common/entityData.js";
export * from "./common/pagePaginationSchemas.js";
export * from "./customers/crud/listCustomersParamsV2.js";
export * from "./customers/cusFeatures/cusFeatureLegacyData.js";
export * from "./customers/cusPlans/previousVersions/apiCusProductV3.js";
export * from "./entities/apiBaseEntity.js";
// Errors
export * from "./errors/index.js";

View File

@@ -0,0 +1,45 @@
import { AttachScenario } from "@models/checkModels/checkPreviewModels.js";
import { AppEnv } from "@models/genModels/genEnums.js";
import { BillingInterval } from "@models/productModels/intervals/billingInterval.js";
import { z } from "zod/v4";
import { ApiFreeTrialV2Schema } from "./components/apiFreeTrialV2.js";
import { DisplaySchema } from "./components/display.js";
import { ApiPlanItemV1Schema } from "./items/apiPlanItemV1.js";
export const ApiPlanV1Schema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
group: z.string().nullable(),
version: z.number(),
add_on: z.boolean(),
auto_enable: z.boolean(),
price: z
.object({
amount: z.number(),
interval: z.enum(BillingInterval),
interval_count: z.number().optional(),
display: DisplaySchema.optional(),
})
.nullable(),
items: z.array(ApiPlanItemV1Schema),
free_trial: ApiFreeTrialV2Schema.optional(),
// Misc
created_at: z.number(),
env: z.enum(AppEnv),
archived: z.boolean(),
base_variant_id: z.string().nullable(),
customer_eligibility: z
.object({
trial_available: z.boolean().optional(),
scenario: z.enum(AttachScenario),
})
.optional(),
});
export type ApiPlanV1 = z.infer<typeof ApiPlanV1Schema>;

View File

@@ -8,8 +8,11 @@ import type { PriceItem } from "@models/productV2Models/productItemModels/priceI
import { isPriceItem } from "@utils/index.js";
import type { SharedContext } from "../../../types/sharedContext.js";
import { productV2ToProperties } from "../../../utils/productV2Utils/productV2ToProperties.js";
import { type ApiPlan, ApiPlanSchema } from "../apiPlan.js";
import { PlanLegacyDataSchema } from "../planLegacyData.js";
import {
type ApiPlan,
ApiPlanV0Schema,
} from "../previousVersions/apiPlanV0.js";
import {
type ApiProduct,
ApiProductSchema,
@@ -53,7 +56,7 @@ export const V1_2_ProductChanges = defineVersionChange({
"Boolean fields renamed (add_on <- is_add_on, default <- is_default)",
],
affectedResources: [AffectedResource.Product],
newSchema: ApiPlanSchema,
newSchema: ApiPlanV0Schema,
oldSchema: ApiProductSchema,
legacyDataSchema: PlanLegacyDataSchema,
@@ -74,15 +77,16 @@ export const V1_2_ProductChanges = defineVersionChange({
const productItems = planV0ToProductItems({
ctx,
plan: input,
})
.filter((x) => {
if (isPriceItem(x)) {
const y: PriceItem = x as unknown as PriceItem;
return y.price > 0;
} else {
return true;
}
});
}).filter((x) => {
if (isPriceItem(x)) {
const y: PriceItem = x as unknown as PriceItem;
return y.price > 0;
} else {
return true;
}
});
console.log(productItems);
const productV2 = {
id: input.id,

View File

@@ -0,0 +1,34 @@
import { type ApiPlanV1, ApiPlanV1Schema } from "@api/products/apiPlanV1.js";
import { planV1ToV0 } from "@api/products/mappers/planV1ToV0.js";
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { PlanLegacyDataSchema } from "../planLegacyData.js";
import {
type ApiPlan,
ApiPlanV0Schema,
} from "../previousVersions/apiPlanV0.js";
export const V2_0_PlanChanges = defineVersionChange({
newVersion: ApiVersion.V2_1,
oldVersion: ApiVersion.V2_0,
description: [
"Plan format changed from V2.0 to V2.1 schema",
"Renamed default to auto_enable",
"Renamed granted_balance to included",
"Removed reset_when_enabled",
],
affectedResources: [AffectedResource.Product],
newSchema: ApiPlanV1Schema,
oldSchema: ApiPlanV0Schema,
legacyDataSchema: PlanLegacyDataSchema,
affectsRequest: false,
affectsResponse: true,
transformResponse: ({ input }: { input: ApiPlanV1 }): ApiPlan => {
return planV1ToV0(input);
},
});

View File

@@ -0,0 +1,10 @@
import { FreeTrialDuration } from "@models/productModels/freeTrialModels/freeTrialEnums.js";
import { z } from "zod/v4";
export const ApiFreeTrialV2Schema = z.object({
duration_type: z.enum(FreeTrialDuration),
duration_length: z.number(),
card_required: z.boolean(),
});
export type ApiFreeTrialV2 = z.infer<typeof ApiFreeTrialV2Schema>;

View File

@@ -0,0 +1,4 @@
export enum BillingMethod {
Prepaid = "prepaid",
UsageBased = "usage_based",
}

View File

@@ -1,8 +1,8 @@
import { BillingInterval } from "@models/productModels/intervals/billingInterval.js";
import { idRegex } from "@utils/utils.js";
import { z } from "zod/v4";
import { ApiFreeTrialV2Schema } from "../apiPlan.js";
import { CreatePlanItemParamsV0Schema } from "../items/crud/createPlanItemV0Params.js";
import { ApiFreeTrialV2Schema } from "../previousVersions/apiPlanV0.js";
export const PlanPriceSchema = z.object({
amount: z.number(),

View File

@@ -2,12 +2,15 @@ import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems
import { planV0ToProductV2 } from "@api/products/mappers/planV0ToProductV2.js";
export * from "./apiFreeTrial.js";
export * from "./apiPlan.js";
export * from "./apiPlanV1.js";
export * from "./components/apiFreeTrialV2.js";
export * from "./components/billingMethod.js";
export * from "./components/display.js";
export * from "./crud/planOpModels.js";
export * from "./items/index.js";
export * from "./mappers/index.js";
export * from "./planLegacyData.js";
export * from "./previousVersions/apiPlanV0.js";
export * from "./previousVersions/apiProduct.js";
export * from "./productOpModels.js";
export * from "./productsOpenApi.js";

View File

@@ -0,0 +1,106 @@
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 { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import {
OnDecrease,
OnIncrease,
} from "@models/productV2Models/productItemModels/productItemEnums.js";
import { z } from "zod/v4";
export const ApiPlanItemV1Schema = z
.object({
feature_id: z.string(),
feature: ApiFeatureV0Schema.optional(),
included: z.number(),
unlimited: z.boolean(),
reset: z
.object({
interval: z.enum(ResetInterval),
interval_count: z.number().optional(),
})
.nullable(),
price: z
.object({
amount: z.number().optional(),
tiers: z.array(UsageTierSchema).optional(),
interval: z.enum(BillingInterval),
interval_count: z.number().optional(),
billing_units: z.number(),
billing_method: z.enum(BillingMethod),
max_purchase: z.number().nullable(),
})
.nullable(),
display: DisplaySchema.optional(),
rollover: z
.object({
max: z.number().nullable(),
expiry_duration_type: z.enum(RolloverExpiryDurationType),
expiry_duration_length: z.number().optional(),
})
.optional(),
proration: z
.object({
on_increase: z.enum(OnIncrease).optional(),
on_decrease: z.enum(OnDecrease).optional(),
})
.optional(),
})
.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: "PlanFeatureV1",
description: "Plan feature object returned by the API (V1/latest)",
example: {
feature_id: "123",
included: 100,
unlimited: false,
price: null,
},
});

View File

@@ -1,8 +1,10 @@
import { planItemV0ToProductItem } from "@api/products/items/mappers/planItemV0ToProductItem.js";
export * from "./apiPlanItemV0.js";
export * from "./apiPlanItemV1.js";
export * from "./crud/createPlanItemV0Params.js";
export * from "./mappers/planItemV0ToProductItem.js";
export * from "./mappers/planItemV1ToV0.js";
export * from "./previousVersions/apiPlanItemV0.js";
export * from "./previousVersions/apiProductItemV0.js";
export const apiPlanItem = {

View File

@@ -1,4 +1,4 @@
import type { ApiPlanItemV0 } from "@api/products/items/apiPlanItemV0.js";
import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js";
import type { ProrationConfig } from "@models/productModels/priceModels/priceModels.js";
import { Infinite } from "@models/productModels/productEnums.js";
import {
@@ -15,7 +15,7 @@ import {
import { dbToApiFeatureV1 } from "@utils/featureUtils/apiFeatureToDbFeature.js";
import { featureToItemFeatureType } from "@utils/featureUtils/convertFeatureUtils.js";
import { resetIntvToItemIntv } from "@utils/planFeatureUtils/planFeatureIntervals.js";
import { resetIntvToItemIntv } from "@utils/productV2Utils/productItemUtils/convertProductItem/planItemIntervals.js";
import { billingToItemInterval } from "@utils/productV2Utils/productItemUtils/itemIntervalUtils.js";
import type { SharedContext } from "../../../../types/sharedContext.js";
import {

View File

@@ -0,0 +1,40 @@
import { BillingMethod } from "@api/products/components/billingMethod.js";
import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js";
import { UsageModel } from "@models/productV2Models/productItemModels/productItemModels.js";
import type { ApiPlanItemV1 } from "../apiPlanItemV1.js";
/** Convert billing_method (V1) to usage_model (V0) */
export function billingMethodToUsageModel(
billingMethod: BillingMethod,
): UsageModel {
return billingMethod === BillingMethod.Prepaid
? UsageModel.Prepaid
: UsageModel.PayPerUse;
}
/** Transform ApiPlanItemV1 to ApiPlanItemV0 */
export function planItemV1ToV0(item: ApiPlanItemV1): ApiPlanItemV0 {
const { included, price, ...restItem } = item;
return {
...restItem,
granted_balance: included,
reset: item.reset
? {
interval: item.reset.interval,
interval_count: item.reset.interval_count,
reset_when_enabled: false,
}
: null,
price: price
? {
amount: price.amount,
tiers: price.tiers,
interval: price.interval,
interval_count: price.interval_count,
billing_units: price.billing_units,
usage_model: billingMethodToUsageModel(price.billing_method),
max_purchase: price.max_purchase,
}
: null,
};
}

View File

@@ -1,15 +1,15 @@
import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0.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 { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import { UsageModel } from "@models/productV2Models/productItemModels/productItemModels.js";
import { z } from "zod/v4";
import { RolloverExpiryDurationType } from "../../../models/productModels/durationTypes/rolloverExpiryDurationType.js";
import { BillingInterval } from "../../../models/productModels/intervals/billingInterval.js";
import {
OnDecrease,
OnIncrease,
} from "../../../models/productV2Models/productItemModels/productItemEnums.js";
import { ApiFeatureV0Schema } from "../../features/prevVersions/apiFeatureV0.js";
import { DisplaySchema } from "../components/display.js";
} from "@models/productV2Models/productItemModels/productItemEnums.js";
import { UsageModel } from "@models/productV2Models/productItemModels/productItemModels.js";
import { z } from "zod/v4";
export const ApiPlanItemV0Schema = z
.object({

View File

@@ -1,5 +1,5 @@
import type { ApiPlanItemV0 } from "@api/products/items/apiPlanItemV0.js";
import type { CreatePlanItemParamsV0 } from "@api/products/items/crud/createPlanItemV0Params.js";
import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js";
import { notNullish } from "@utils/utils.js";
type PlanFeatureWithReset = (ApiPlanItemV0 | CreatePlanItemParamsV0) & {

View File

@@ -1 +1,2 @@
export * from "./planV0ToProductItems.js";
export * from "./planV1ToV0.js";

View File

@@ -1,8 +1,8 @@
import type { ApiPlan } from "@api/products/apiPlan";
import type {
CreatePlanParams,
UpdatePlanParams,
} from "@api/products/crud/planOpModels";
import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0";
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
import {
type ProductItem,

View File

@@ -3,8 +3,8 @@ import {
type CreatePlanParams,
type UpdatePlanParams,
} from "@api/models";
import type { ApiPlan } from "@api/products/apiPlan";
import { planV0ToBasePriceProductItem } from "@api/products/mappers/planV0ToBasePriceProductItem";
import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0";
import type { ProductItem } from "@models/productV2Models/productItemModels/productItemModels";
import type { SharedContext } from "../../../types";

View File

@@ -1,9 +1,9 @@
import type { ApiPlan } from "@api/products/apiPlan";
import type {
CreatePlanParams,
UpdatePlanParams,
} from "@api/products/crud/planOpModels";
import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems";
import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0";
import type {
CreateProductV2Params,
UpdateProductV2Params,

View File

@@ -0,0 +1,18 @@
import type { ApiPlanV1 } from "@api/products/apiPlanV1.js";
import { planItemV1ToV0 } from "@api/products/items/mappers/planItemV1ToV0.js";
import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0.js";
/**
* Transform ApiPlanV1 to ApiPlan (V0)
*
* Handles the following conversions:
* - auto_enable -> default
* - features: ApiPlanItemV1[] -> ApiPlanItemV0[]
*/
export function planV1ToV0(plan: ApiPlanV1): ApiPlan {
return {
...plan,
default: plan.auto_enable,
features: plan.items.map(planItemV1ToV0),
};
}

View File

@@ -1,20 +1,17 @@
import { AttachScenario } from "@models/checkModels/checkPreviewModels.js";
import { AppEnv } from "@models/genModels/genEnums.js";
import { FreeTrialDuration } from "@models/productModels/freeTrialModels/freeTrialEnums.js";
import { BillingInterval } from "@models/productModels/intervals/billingInterval.js";
import { z } from "zod/v4";
import { DisplaySchema } from "./components/display.js";
import { ApiPlanItemV0Schema } from "./items/apiPlanItemV0.js";
import { ApiFreeTrialV2Schema } from "../components/apiFreeTrialV2.js";
import { DisplaySchema } from "../components/display.js";
import { ApiPlanItemV0Schema } from "../items/previousVersions/apiPlanItemV0.js";
export const ApiFreeTrialV2Schema = z.object({
duration_type: z.enum(FreeTrialDuration),
duration_length: z.number(),
card_required: z.boolean(),
});
export {
type ApiFreeTrialV2,
ApiFreeTrialV2Schema,
} from "../components/apiFreeTrialV2.js";
export type ApiFreeTrialV2 = z.infer<typeof ApiFreeTrialV2Schema>;
export const ApiPlanSchema = z.object({
export const ApiPlanV0Schema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
@@ -51,4 +48,4 @@ export const ApiPlanSchema = z.object({
.optional(),
});
export type ApiPlan = z.infer<typeof ApiPlanSchema>;
export type ApiPlan = z.infer<typeof ApiPlanV0Schema>;

View File

@@ -4,6 +4,7 @@
* Internally we use SemVer for comparison (e.g., "1.1.0")
*/
export enum ApiVersion {
V2_1 = "2.1.0",
V2_0 = "2.0.0",
V1_Beta = "beta",
V1_2 = "1.2.0",
@@ -16,4 +17,4 @@ export type ApiVersionString = `${ApiVersion}`;
export const API_VERSIONS = Object.values(ApiVersion);
export const LATEST_VERSION = ApiVersion.V2_0;
export const LATEST_VERSION = ApiVersion.V2_1;

View File

@@ -13,6 +13,7 @@ import { V1_2_CustomerChange } from "@api/customers/changes/V1.2_CustomerChange.
import { V1_2_CustomerQueryChange } from "@api/customers/requestChanges/V1.2_CustomerQueryChange.js";
// Import entity changes
import { V1_2_EntityChange } from "@api/entities/changes/V1.2_EntityChange.js";
import { V2_0_EntityChange } from "@api/entities/changes/V2.0_EntityChange.js";
import { V1_2_EntityQueryChange } from "@api/entities/requestChanges/V1.2_EntityQueryChange.js";
// Import feature changes
import { V1_2_FeatureChange } from "@api/features/changes/V1.2_FeatureChange.js";
@@ -22,7 +23,9 @@ import { V1_2_InvoiceChange } from "@api/others/apiInvoice/changes/V1.2_InvoiceC
// Import product changes
import { V2_0_CustomerChange } from "@api/customers/changes/V2.0_CustomerChange.js";
import { V1_2_ProductChanges } from "@api/products/changes/V1.2_ProductChanges.js";
import { V2_0_PlanChanges } from "@api/products/changes/V2.0_PlanChanges.js";
import { V0_2_CheckChange } from "../../balances/check/changes/V0.2_CheckChange.js";
import { V1_2_CheckChange } from "../../balances/check/changes/V1.2_CheckChange.js";
import { V1_2_CheckQueryChange } from "../../balances/check/changes/V1.2_CheckQueryChange.js";
@@ -34,6 +37,12 @@ import { ApiVersion } from "../ApiVersion.js";
import type { VersionChangeConstructor } from "./VersionChange.js";
import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js";
export const V2_1_CHANGES: VersionChangeConstructor[] = [
V2_0_PlanChanges, // Transforms Plan TO V2.1 format from V2.0 format
V2_0_CustomerChange, // Transforms Customer TO V2.1 format from V2.0 format
V2_0_EntityChange, // Transforms Entity TO V2.1 format from V2.0 format
];
export const V2_CHANGES: VersionChangeConstructor[] = [
V1_2_CustomerChange, // Transforms Customer TO V1.2 format from V2 format
V1_2_CustomerQueryChange, // Transforms Customer Query TO V2.0 format (adds expand options)
@@ -73,6 +82,10 @@ export const V0_2_CHANGES: VersionChangeConstructor[] = [
export const V0_1_CHANGES: VersionChangeConstructor[] = [];
export function registerAllVersionChanges() {
VersionChangeRegistryClass.register({
version: ApiVersion.V2_1,
changes: V2_1_CHANGES,
});
VersionChangeRegistryClass.register({
version: ApiVersion.V2_0,
changes: V2_CHANGES,

View File

@@ -14,6 +14,12 @@ export interface VersionMetadata {
* SemVer ↔ CalVer mappings and metadata
*/
export const VERSION_REGISTRY: Record<ApiVersion, VersionMetadata> = {
[ApiVersion.V2_1]: {
semver: ApiVersion.V2_1,
calver: "2026-04-01",
releasedAt: new Date("2026-04-01").getTime(),
description: "Plan items with max_purchase",
},
[ApiVersion.V2_0]: {
semver: ApiVersion.V2_0,
calver: "2026-03-31",

View File

@@ -86,7 +86,7 @@ export * from "./models/featureModels/featureModels.js";
// export * from "./models/featureModels/featureResModels.js";
export * from "./api/products/crud/planOpModels.js";
export * from "./api/products/items/apiPlanItemV0.js";
export * from "./api/products/items/previousVersions/apiPlanItemV0.js";
// 2. Feature Models
export * from "./models/featureModels/featureTable.js";
@@ -189,12 +189,7 @@ export * from "./utils/cusEntUtils/index";
export * from "./utils/displayUtils.js";
export * from "./utils/index.js";
export * from "./utils/intervalUtils.js";
export * from "./utils/planFeatureUtils/itemsToPlanFeatures.js";
export * from "./utils/planFeatureUtils/itemsToPlanFeatures.js";
export * from "./utils/planFeatureUtils/planFeatureIntervals.js";
export * from "./utils/planFeatureUtils/planFeatureIntervals.js";
export * from "./utils/planFeatureUtils/planToDbFreeTrial.js";
export * from "./utils/productDisplayUtils/sortProductItems.js";
export * from "./utils/productDisplayUtils.js";
export * from "./utils/productUtils/convertProductUtils.js";
@@ -209,6 +204,8 @@ export * from "./utils/productV2Utils/compareProductUtils/generateTrialChanges.j
export * from "./utils/productV2Utils/compareProductUtils/generateVersionChanges.js";
export * from "./utils/productV2Utils/compareProductUtils/itemEditTypes.js";
export * from "./utils/productV2Utils/productItemUtils/convertItemUtils.js";
export * from "./utils/productV2Utils/productItemUtils/convertProductItem/planItemIntervals.js";
export * from "./utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemV1.js";
export * from "./utils/productV2Utils/productItemUtils/getProductItemRes.js";
export * from "./utils/productV2Utils/productItemUtils/itemIntervalUtils.js";
export * from "./utils/productV3Utils/productItemUtils/productV3ItemUtils.js";

View File

@@ -1,9 +1,8 @@
import { ApiPlanV1Schema } from "@api/products/apiPlanV1.js";
import { FeatureOptionsSchema } from "@models/cusProductModels/cusProductModels.js";
import { z } from "zod/v4";
import { BillingPreviewResponseSchema } from "../../api/billing/common/billingPreviewResponse.js";
import { ApiBalanceSchema } from "../../api/customers/cusFeatures/apiBalance.js";
import { ApiSubscriptionSchema } from "../../api/customers/cusPlans/apiSubscription.js";
import { ApiPlanSchema } from "../../api/products/apiPlan.js";
/**
* Org branding for checkout display
@@ -30,18 +29,18 @@ export const CheckoutEntitySchema = z.object({
name: z.string().nullable(),
});
/**
* Subscription with required plan (always expanded for checkout)
*/
export const CheckoutSubscriptionSchema = ApiSubscriptionSchema.extend({
plan: ApiPlanSchema,
});
// /**
// * Subscription with required plan (always expanded for checkout)
// */
// export const CheckoutSubscriptionSchema = ApiSubscriptionSchema.extend({
// plan: ApiPlanV0Schema,
// });
/**
* A change in the checkout (product being added, canceled, or expiring)
*/
export const CheckoutChangeSchema = z.object({
plan: ApiPlanSchema,
plan: ApiPlanV1Schema,
feature_quantities: z.array(
FeatureOptionsSchema.pick({
feature_id: true,
@@ -80,7 +79,7 @@ export const ConfirmCheckoutResponseSchema = z.object({
export type CheckoutOrg = z.infer<typeof CheckoutOrgSchema>;
export type CheckoutCustomer = z.infer<typeof CheckoutCustomerSchema>;
export type CheckoutEntity = z.infer<typeof CheckoutEntitySchema>;
export type CheckoutSubscription = z.infer<typeof CheckoutSubscriptionSchema>;
// export type CheckoutSubscription = z.infer<typeof CheckoutSubscriptionSchema>;
export type CheckoutChange = z.infer<typeof CheckoutChangeSchema>;
export type GetCheckoutResponse = z.infer<typeof GetCheckoutResponseSchema>;
export type ConfirmCheckoutResponse = z.infer<

View File

@@ -5,7 +5,7 @@ import { isContUseFeature } from "../../featureUtils/convertFeatureUtils";
import {
entIntvToResetIntv,
toIntervalCountResponse,
} from "../../planFeatureUtils/planFeatureIntervals";
} from "../../productV2Utils/productItemUtils/convertProductItem/planItemIntervals.js";
export const cusEntsToNextResetAt = ({
cusEnts,

View File

@@ -2,8 +2,8 @@ import { Decimal } from "decimal.js";
import type { ApiBalanceBreakdown } from "../../api/customers/cusFeatures/apiBalance.js";
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import { resetIntvToEntIntv } from "../planFeatureUtils/planFeatureIntervals.js";
import { entToOptions } from "../productUtils/convertProductUtils.js";
import { resetIntvToEntIntv } from "../productV2Utils/productItemUtils/convertProductItem/planItemIntervals.js";
import { getCusEntBalance } from "./balanceUtils.js";
import { cusEntToCusPrice } from "./convertCusEntUtils/cusEntToCusPrice.js";
import { getRolloverFields } from "./getRolloverFields.js";

View File

@@ -1,235 +0,0 @@
import {
type ApiPlanItemV0,
ApiPlanItemV0Schema,
} from "@api/products/items/apiPlanItemV0.js";
import { CusExpand } from "@models/cusModels/cusExpand.js";
import { Infinite } from "@models/productModels/productEnums.js";
import {
type ProductItem,
UsageModel,
} from "@models/productV2Models/productItemModels/productItemModels.js";
import { InternalError } from "../../api/models.js";
import type { Feature } from "../../models/featureModels/featureModels.js";
import { expandIncludes } from "../expandUtils.js";
import {
isBooleanFeature,
isContUseFeature,
} from "../featureUtils/convertFeatureUtils.js";
import { toApiFeature } from "../featureUtils.js";
import { getProductItemDisplay } from "../productDisplayUtils.js";
import { isFeaturePriceItem } from "../productV2Utils/productItemUtils/getItemType.js";
import { itemToBillingInterval } from "../productV2Utils/productItemUtils/itemIntervalUtils.js";
import { itemIntvToResetIntv } from "./planFeatureIntervals.js";
// const getFeaturePriceItemParams = ({
// item,
// feature,
// }: {
// item: ProductItem;
// feature: Feature;
// }) => {
// // 1. If
// // reset_interval: itemIntvToResetIntv(
// // item.interval!,
// // ) as ResetInterval,
// // ...(item.interval_count !== undefined &&
// // item.interval_count !== null
// // ? {
// // reset_interval_count: item.interval_count,
// // }
// // : {}),
// return {
// interval: item.interval,
// };
// };
const itemToReset = ({
item,
feature,
}: {
item: ProductItem;
feature: Feature;
}) => {
// 1. If continuous use or boolean, no reset
if (isContUseFeature({ feature }) || isBooleanFeature({ feature })) {
return null;
}
return {
interval: itemIntvToResetIntv(item.interval ?? null),
interval_count:
item.interval_count !== 1 && typeof item.interval_count === "number"
? item.interval_count
: undefined,
reset_when_enabled: item.reset_usage_when_enabled ?? false,
} satisfies ApiPlanItemV0["reset"];
};
const itemToPlanFeaturePrice = ({ item }: { item: ProductItem }) => {
if (!isFeaturePriceItem(item)) {
return null;
}
const includedUsage =
item.included_usage === Infinite ? 0 : (item.included_usage ?? 0);
const maxPurchase = item.usage_limit
? item.usage_limit - includedUsage
: null;
const price =
item.tiers && item.tiers.length === 1 ? item.tiers[0].amount : item.price;
const tiers =
item.tiers && item.tiers.length > 1
? item.tiers.map((tier) => ({
to: tier.to,
amount: tier.amount,
}))
: undefined;
return {
amount: price ?? undefined,
tiers: tiers,
interval: itemToBillingInterval({ item }),
interval_count:
item.interval_count !== 1 && typeof item.interval_count === "number"
? item.interval_count
: undefined,
billing_units: item.billing_units ?? 1,
usage_model: item.usage_model || UsageModel.PayPerUse,
max_purchase: maxPurchase,
} satisfies ApiPlanItemV0["price"];
};
const itemToPlanFeatureRollover = ({ item }: { item: ProductItem }) => {
if (!item.config?.rollover) return undefined;
return {
max: item.config.rollover.max ?? null,
expiry_duration_type: item.config.rollover.duration,
expiry_duration_length: item.config.rollover.length,
} satisfies ApiPlanItemV0["rollover"];
};
const itemToPlanFeatureProration = ({ item }: { item: ProductItem }) => {
if (!item.config?.on_increase || !item.config?.on_decrease) return undefined;
if (!isFeaturePriceItem(item)) return undefined;
return {
on_increase: item.config.on_increase,
on_decrease: item.config.on_decrease,
} satisfies ApiPlanItemV0["proration"];
};
export const itemsToPlanFeatures = ({
items,
features,
expand = [],
}: {
items: ProductItem[];
features: Feature[];
expand?: string[];
}): ApiPlanItemV0[] => {
if (!items) return [];
const shouldExpandFeature = expandIncludes({
expand,
includes: [CusExpand.PlanFeaturesFeature],
});
return items.map((item) => {
const feature = features.find((f) => f.id === item.feature_id);
if (!item.feature_id || !feature) {
throw new InternalError({
message:
"Converting item to plan feature: item has no feature ID or feature not found",
});
}
// 1. Granted balance
const grantedBalance =
item.included_usage === Infinite ? 0 : (item.included_usage ?? 0);
const reset = itemToReset({ item, feature });
const price = itemToPlanFeaturePrice({ item });
const rollover = itemToPlanFeatureRollover({ item });
const proration = itemToPlanFeatureProration({ item });
// Convert feature to API format if expand requested
const apiFeature = shouldExpandFeature
? toApiFeature({ feature })
: undefined;
return ApiPlanItemV0Schema.parse({
feature_id: item.feature_id,
feature: apiFeature,
granted_balance: grantedBalance,
unlimited: item.included_usage === Infinite,
reset,
price,
rollover,
proration,
display: getProductItemDisplay({ item, features }),
// Other fields
// entity_feature_id: item.entity_feature_id,
} satisfies ApiPlanItemV0);
});
};
// // Conditionally set reset_interval OR price.interval (mutually exclusive)
// // If has pricing: interval goes in price
// // If no pricing: interval goes in reset_interval
// ...(!hasPrice && item.interval
// ? {
// reset_interval: itemIntvToResetIntv(
// item.interval!,
// ) as ResetInterval,
// ...(item.interval_count !== undefined &&
// item.interval_count !== null
// ? {
// reset_interval_count: item.interval_count,
// }
// : {}),
// }
// : {}),
// ...(hasPrice
// ? (() => {
// // Check if this is a single tier to infinity (stored as tier but should be flat amount)
// const isSingleTierToInf =
// item.tiers &&
// item.tiers.length === 1 &&
// item.tiers[0].to === TierInfinite;
// return {
// price: {
// interval: (item.interval || "month") as BillingInterval,
// billing_units: item.billing_units ?? 1,
// usage_model: (item.usage_model || "pay_per_use") as UsageModel,
// max_purchase: 1,
// // If single tier to infinity, extract amount; otherwise use item.price
// amount: isSingleTierToInf
// ? item.tiers![0].amount
// : item.price || 0,
// // Only include tiers if multi-tier
// ...(item.tiers && item.tiers.length > 1
// ? {
// tiers: item.tiers.map((tier) => ({
// to: tier.to === TierInfinite ? TierInfinite : tier.to,
// amount: tier.amount,
// })),
// }
// : {}),
// interval_count: item.interval_count ?? undefined,
// },
// };
// })()
// : {}),

View File

@@ -0,0 +1,178 @@
import { BillingMethod } from "@api/products/components/billingMethod.js";
import {
type ApiPlanItemV1,
ApiPlanItemV1Schema,
} from "@api/products/items/apiPlanItemV1.js";
import { CusExpand } from "@models/cusModels/cusExpand.js";
import { Infinite } from "@models/productModels/productEnums.js";
import {
type ProductItem,
UsageModel,
} from "@models/productV2Models/productItemModels/productItemModels.js";
import { InternalError } from "../../../../api/models.js";
import type { Feature } from "../../../../models/featureModels/featureModels.js";
import { expandIncludes } from "../../../expandUtils.js";
import {
isBooleanFeature,
isContUseFeature,
} from "../../../featureUtils/convertFeatureUtils.js";
import { toApiFeature } from "../../../featureUtils.js";
import { getProductItemDisplay } from "../../../productDisplayUtils.js";
import { isFeaturePriceItem } from "../getItemType.js";
import { itemToBillingInterval } from "../itemIntervalUtils.js";
import { itemIntvToResetIntv } from "./planItemIntervals.js";
const itemToReset = ({
item,
feature,
}: {
item: ProductItem;
feature: Feature;
}): ApiPlanItemV1["reset"] => {
// 1. If continuous use or boolean, no reset
if (isContUseFeature({ feature }) || isBooleanFeature({ feature })) {
return null;
}
return {
interval: itemIntvToResetIntv(item.interval ?? null),
interval_count:
item.interval_count !== 1 && typeof item.interval_count === "number"
? item.interval_count
: undefined,
// Note: reset_when_enabled is NOT in V1 schema - removed
};
};
const itemToPlanFeaturePrice = ({
item,
}: {
item: ProductItem;
}): ApiPlanItemV1["price"] => {
if (!isFeaturePriceItem(item)) {
return null;
}
const includedUsage =
item.included_usage === Infinite ? 0 : (item.included_usage ?? 0);
const maxPurchase = item.usage_limit
? item.usage_limit - includedUsage
: null;
const price =
item.tiers && item.tiers.length === 1 ? item.tiers[0].amount : item.price;
const tiers =
item.tiers && item.tiers.length > 1
? item.tiers.map((tier) => ({
to: tier.to,
amount: tier.amount,
}))
: undefined;
// V1 schema uses billing_method, NOT usage_model
const billingMethod =
item.usage_model === UsageModel.PayPerUse
? BillingMethod.UsageBased
: BillingMethod.Prepaid;
return {
amount: price ?? undefined,
tiers: tiers,
interval: itemToBillingInterval({ item }),
interval_count:
item.interval_count !== 1 && typeof item.interval_count === "number"
? item.interval_count
: undefined,
billing_units: item.billing_units ?? 1,
billing_method: billingMethod,
max_purchase: maxPurchase,
};
};
const itemToPlanFeatureRollover = ({
item,
}: {
item: ProductItem;
}): ApiPlanItemV1["rollover"] => {
if (!item.config?.rollover) return undefined;
return {
max: item.config.rollover.max ?? null,
expiry_duration_type: item.config.rollover.duration,
expiry_duration_length: item.config.rollover.length,
};
};
const itemToPlanFeatureProration = ({
item,
}: {
item: ProductItem;
}): ApiPlanItemV1["proration"] => {
if (!item.config?.on_increase || !item.config?.on_decrease) return undefined;
if (!isFeaturePriceItem(item)) return undefined;
return {
on_increase: item.config.on_increase,
on_decrease: item.config.on_decrease,
};
};
export const productItemsToPlanItemsV1 = ({
items,
features,
expand = [],
}: {
items: ProductItem[];
features: Feature[];
expand?: string[];
}): ApiPlanItemV1[] => {
if (!items) return [];
const shouldExpandFeature = expandIncludes({
expand,
includes: [CusExpand.PlanFeaturesFeature],
});
return items.map((item) => {
const feature = features.find((f) => f.id === item.feature_id);
if (!item.feature_id || !feature) {
throw new InternalError({
message:
"Converting item to plan feature: item has no feature ID or feature not found",
});
}
// 1. Included balance (V1 uses "included", not "granted_balance")
const included =
item.included_usage === Infinite ? 0 : (item.included_usage ?? 0);
const reset = itemToReset({ item, feature });
const price = itemToPlanFeaturePrice({ item });
const rollover = itemToPlanFeatureRollover({ item });
const proration = itemToPlanFeatureProration({ item });
// Convert feature to API format if expand requested
const apiFeature = shouldExpandFeature
? toApiFeature({ feature })
: undefined;
return ApiPlanItemV1Schema.parse({
feature_id: item.feature_id,
feature: apiFeature,
included: included,
unlimited: item.included_usage === Infinite,
reset,
price, // V1: price can be null (no need for conditional spread)
rollover,
proration,
display: getProductItemDisplay({ item, features }),
});
});
};