feat: new attach / update subscription params

This commit is contained in:
John Yeo
2026-02-17 12:48:11 +00:00
parent a668c5d9e9
commit e6077458a2
112 changed files with 2555 additions and 419 deletions

View File

@@ -11,8 +11,6 @@ import {
type AttachBodyV0,
type AttachParamsV0Input,
type BalancesUpdateParams,
type BillingPreviewResponse,
type BillingResponse,
type CheckQuery,
type CreateBalanceParamsV0,
type CreateCustomerInternalOptions,
@@ -263,13 +261,13 @@ export class AutumnInt {
return data;
}
async attach(
params: AttachBodyV0,
async attach<TInput = AttachBodyV0>(
params: TInput,
{
skipWebhooks,
idempotencyKey,
}: { skipWebhooks?: boolean; idempotencyKey?: string } = {},
) {
): Promise<any> {
const headers: Record<string, string> = {};
if (skipWebhooks !== undefined) {
headers["x-skip-webhooks"] = skipWebhooks ? "true" : "false";
@@ -815,13 +813,13 @@ export class AutumnInt {
};
subscriptions = {
update: async (
params: UpdateSubscriptionV0Params,
update: async <TInput = UpdateSubscriptionV0Params>(
params: TInput,
{
timeout,
skipWebhooks,
}: { timeout?: number; skipWebhooks?: boolean } = {},
): Promise<BillingResponse> => {
): Promise<any> => {
const headers: Record<string, string> = {};
if (skipWebhooks !== undefined) {
headers["x-skip-webhooks"] = skipWebhooks ? "true" : "false";
@@ -838,15 +836,21 @@ export class AutumnInt {
return data;
},
previewUpdate: async (params: UpdateSubscriptionV0Params) => {
previewUpdate: async <TInput = UpdateSubscriptionV0Params>(
params: TInput,
): Promise<any> => {
const data = await this.post(`/subscriptions/preview_update`, params);
return data;
},
};
billing = {
attach: async (
params: Omit<AttachParamsV0Input, "items"> & { items?: ProductItem[] },
attach: async <
TInput = Omit<AttachParamsV0Input, "items"> & {
items?: ProductItem[];
},
>(
params: TInput,
{
skipWebhooks,
idempotencyKey,
@@ -856,7 +860,7 @@ export class AutumnInt {
idempotencyKey?: string;
timeout?: number;
} = {},
) => {
): Promise<any> => {
const headers: Record<string, string> = {};
if (skipWebhooks !== undefined) {
headers["x-skip-webhooks"] = skipWebhooks ? "true" : "false";
@@ -877,9 +881,13 @@ export class AutumnInt {
return data;
},
previewAttach: async (
params: Omit<AttachParamsV0Input, "items"> & { items?: ProductItem[] },
): Promise<BillingPreviewResponse> => {
previewAttach: async <
TInput = Omit<AttachParamsV0Input, "items"> & {
items?: ProductItem[];
},
>(
params: TInput,
): Promise<any> => {
const data = await this.post(`/billing/preview_attach`, {
...params,
redirect_mode: "if_required",

View File

@@ -122,6 +122,7 @@ export const versionedValidator = ({
fromVersion: userVersion,
toVersion: new ApiVersionClass(LATEST_VERSION),
resource,
ctx,
});
// Replace validated data with transformed version

View File

@@ -1,9 +1,9 @@
import {
type AttachBillingContext,
type AttachParamsV0,
type BillingContextOverride,
type BillingPlan,
type BillingResult,
import type {
AttachBillingContext,
AttachParamsV1,
BillingContextOverride,
BillingPlan,
BillingResult,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { computeAttachPlan } from "@/internal/billing/v2/actions/attach/compute/computeAttachPlan";
@@ -31,7 +31,7 @@ export async function attach({
contextOverride,
}: {
ctx: AutumnContext;
params: AttachParamsV0;
params: AttachParamsV1;
preview?: boolean;
skipAutumnCheckout?: boolean;
contextOverride?: BillingContextOverride;

View File

@@ -1,6 +1,6 @@
import type {
AttachBillingContext,
AttachParamsV0,
AttachParamsV1,
AutumnBillingPlan,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
@@ -25,7 +25,7 @@ export const computeAttachPlan = ({
}: {
ctx: AutumnContext;
attachBillingContext: AttachBillingContext;
params: AttachParamsV0;
params: AttachParamsV1;
}): AutumnBillingPlan => {
const {
currentCustomerProduct,

View File

@@ -1,6 +1,6 @@
import type {
AttachBillingContext,
AttachParamsV0,
AttachParamsV1,
AutumnBillingPlan,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
@@ -19,7 +19,7 @@ export const finalizeAttachPlan = ({
ctx: AutumnContext;
plan: AutumnBillingPlan;
attachBillingContext: AttachBillingContext;
params: AttachParamsV0;
params: AttachParamsV1;
}): AutumnBillingPlan => {
plan.lineItems = finalizeLineItems({
ctx,

View File

@@ -1,6 +1,6 @@
import type {
AttachBillingContext,
AttachParamsV0,
AttachParamsV1,
BillingPlan,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
@@ -20,11 +20,11 @@ export async function createAutumnCheckout({
billingPlan,
}: {
ctx: AutumnContext;
params: AttachParamsV0;
params: AttachParamsV1;
billingContext: AttachBillingContext;
billingPlan: BillingPlan;
}): Promise<AttachResult> {
const { checkout, checkoutUrl } = await billingPlanToAutumnCheckout({
const { checkout } = await billingPlanToAutumnCheckout({
ctx,
params,
billingContext,

View File

@@ -1,11 +1,11 @@
import {
type AttachBillingContext,
type AttachParamsV1,
type AutumnBillingPlan,
cusProductToPrices,
ErrCode,
isFreeProduct,
RecaseError,
type AttachBillingContext,
type AttachParamsV0,
type AutumnBillingPlan,
} from "@autumn/shared";
import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition";
@@ -22,7 +22,7 @@ export const handleAttachBillingBehaviorErrors = ({
}: {
billingContext: AttachBillingContext;
autumnBillingPlan: AutumnBillingPlan;
params: AttachParamsV0;
params: AttachParamsV1;
}) => {
// Only validate when billing_behavior is 'next_cycle_only' (defer charges)
if (params.billing_behavior !== "next_cycle_only") return;

View File

@@ -1,6 +1,6 @@
import type {
AttachBillingContext,
AttachParamsV0,
AttachParamsV1,
AutumnBillingPlan,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
@@ -24,7 +24,7 @@ export const handleAttachV2Errors = ({
ctx: AutumnContext;
billingContext: AttachBillingContext;
autumnBillingPlan: AutumnBillingPlan;
params: AttachParamsV0;
params: AttachParamsV1;
}) => {
// 1. External PSP errors (RevenueCat)
handleExternalPSPErrors({

View File

@@ -1,12 +1,9 @@
import type {
AttachBillingContext,
AttachParamsV1,
BillingContextOverride,
} from "@autumn/shared";
import {
type AttachParamsV0,
BillingVersion,
notNullish,
} from "@autumn/shared";
import { BillingVersion, notNullish } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext";
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
@@ -30,7 +27,7 @@ export const setupAttachBillingContext = async ({
contextOverride = {},
}: {
ctx: AutumnContext;
params: AttachParamsV0;
params: AttachParamsV1;
contextOverride?: BillingContextOverride;
}): Promise<AttachBillingContext> => {
const { fullCustomer: fullCustomerOverride } = contextOverride;
@@ -84,7 +81,7 @@ export const setupAttachBillingContext = async ({
});
const invoiceMode = setupInvoiceModeContext({ params });
const isCustom = notNullish(params.items);
const isCustom = notNullish(params.customize);
// Timestamp context
const currentEpochMs = testClockFrozenTime ?? Date.now();

View File

@@ -1,4 +1,4 @@
import type { AttachParamsV0, BillingContextOverride } from "@autumn/shared";
import type { AttachParamsV1, BillingContextOverride } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { ProductService } from "@/internal/products/ProductService";
import { setupCustomFullProduct } from "../../../setup/setupCustomFullProduct";
@@ -12,7 +12,7 @@ export const setupAttachProductContext = async ({
contextOverride = {},
}: {
ctx: AutumnContext;
params: AttachParamsV0;
params: AttachParamsV1;
contextOverride?: BillingContextOverride;
}) => {
const { productContext } = contextOverride;
@@ -37,7 +37,7 @@ export const setupAttachProductContext = async ({
} = await setupCustomFullProduct({
ctx,
currentFullProduct: fullProduct,
customItems: params.items,
customizePlan: params.customize,
});
return {

View File

@@ -1,5 +1,5 @@
import {
type AttachParamsV0,
type AttachParamsV1,
type FullCusProduct,
type FullCustomer,
type FullProduct,
@@ -30,7 +30,7 @@ export const setupAttachTrialContext = async ({
currentContext,
}: {
ctx: AutumnContext;
params: AttachParamsV0;
params: AttachParamsV1;
currentContext: {
fullCustomer: FullCustomer;
attachProduct: FullProduct;

View File

@@ -1,5 +1,9 @@
import type { BillingContextOverride, PlanTiming } from "@autumn/shared";
import { type AttachParamsV0, BillingVersion } from "@autumn/shared";
import type {
AttachParamsV1,
BillingContextOverride,
PlanTiming,
} from "@autumn/shared";
import { BillingVersion } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { billingActions } from "@/internal/billing/v2/actions";
import { attachParamsToStripeBillingContext } from "@/internal/billing/v2/actions/legacy/utils/attachParamsToStripeBillingContext";
@@ -44,14 +48,10 @@ export const legacyAttach = async ({
const fullCustomer = attachParams.customer;
const params: AttachParamsV0 = {
const params: AttachParamsV1 = {
customer_id: fullCustomer.id || fullCustomer.internal_id,
entity_id: fullCustomer.entity?.id,
product_id: fullProduct.id,
// items: body.items,
// version: body.version,
// invoice: body.invoice,
// free_trial: body.free_trial === false ? null : undefined,
invoice: attachParams.invoiceOnly,
enable_product_immediately: true,

View File

@@ -1,6 +1,6 @@
import type {
UpdateSubscriptionBillingContextOverride,
UpdateSubscriptionV0Params,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
import {
type AttachBodyV0,
@@ -63,7 +63,7 @@ export const renew = async ({
const fullCustomer = attachParams.customer;
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: fullCustomer.id || fullCustomer.internal_id,
entity_id: fullCustomer.entity?.id,
product_id: fullProduct.id,

View File

@@ -1,6 +1,6 @@
import type {
UpdateSubscriptionBillingContextOverride,
UpdateSubscriptionV0Params,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
import {
type AttachBodyV0,
@@ -64,7 +64,7 @@ export const updateQuantity = async ({
const fullCustomer = attachParams.customer;
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: fullCustomer.id || fullCustomer.internal_id,
entity_id: fullCustomer.entity?.id,
product_id: fullProduct.id,

View File

@@ -6,7 +6,7 @@ import {
type FullCustomer,
type FullProduct,
featureUtils,
type UpdateSubscriptionV0Params,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
import type { FeatureOptionsParamsV0 } from "@shared/api/billing/common/featureOptions/featureOptionsParamsV0";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
@@ -45,7 +45,7 @@ export async function migrate({
reset_after_trial_end: true,
}));
const updateSubscriptionParams: UpdateSubscriptionV0Params = {
const updateSubscriptionParams: UpdateSubscriptionV1Params = {
customer_id: fullCustomer.id || fullCustomer.internal_id,
customer_product_id: currentCustomerProduct.id,
entity_id: entity?.id,

View File

@@ -1,4 +1,4 @@
import type { UpdateSubscriptionV0Params } from "@shared/index";
import type { UpdateSubscriptionV1Params } from "@shared/index";
export enum UpdateSubscriptionIntent {
UpdateQuantity = "update_quantity",
@@ -10,9 +10,9 @@ export enum UpdateSubscriptionIntent {
* Compute the intent for a subscription update
*/
export const computeUpdateSubscriptionIntent = (
params: UpdateSubscriptionV0Params,
params: UpdateSubscriptionV1Params,
): UpdateSubscriptionIntent => {
const itemsChanged = params.items !== undefined;
const itemsChanged = params.customize !== undefined;
const versionChanged = params.version !== undefined;
const freeTrialChanged = params.free_trial !== undefined;

View File

@@ -1,7 +1,9 @@
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
import type {
AutumnBillingPlan,
UpdateSubscriptionBillingContext,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
import type { AutumnBillingPlan } from "@autumn/shared";
import { computeCancelPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/cancel/computeCancelPlan";
@@ -23,7 +25,7 @@ export const computeUpdateSubscriptionPlan = async ({
}: {
ctx: AutumnContext;
billingContext: UpdateSubscriptionBillingContext;
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
}): Promise<AutumnBillingPlan> => {
const intent = computeUpdateSubscriptionIntent(params);
@@ -39,7 +41,6 @@ export const computeUpdateSubscriptionPlan = async ({
plan = await computeCustomPlan({
ctx,
updateSubscriptionContext: billingContext,
params,
});
break;
case UpdateSubscriptionIntent.None:

View File

@@ -2,10 +2,7 @@ import type {
AutumnBillingPlan,
UpdateSubscriptionBillingContext,
} from "@autumn/shared";
import {
CusProductStatus,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import { CusProductStatus } from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import { computeDeleteCustomerProduct } from "@/internal/billing/v2/actions/updateSubscription/compute/computeDeleteCustomerProduct";
import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct";
@@ -14,11 +11,9 @@ import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutum
export const computeCustomPlan = async ({
ctx,
updateSubscriptionContext,
params,
}: {
ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionBillingContext;
params: UpdateSubscriptionV0Params;
}) => {
const {
customerProduct,

View File

@@ -2,7 +2,7 @@ import {
type AutumnBillingPlan,
isCustomerProductOneOff,
type UpdateSubscriptionBillingContext,
type UpdateSubscriptionV0Params,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { finalizeLineItems } from "@/internal/billing/v2/compute/finalize/finalizeLineItems";
@@ -20,7 +20,7 @@ export const finalizeUpdateSubscriptionPlan = ({
ctx: AutumnContext;
plan: AutumnBillingPlan;
billingContext: UpdateSubscriptionBillingContext;
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
}): AutumnBillingPlan => {
// Finalize line items (shared logic)
plan.lineItems = finalizeLineItems({

View File

@@ -7,7 +7,7 @@ import {
ErrCode,
isFreeProduct,
RecaseError,
type UpdateSubscriptionV0Params,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition";
import {
@@ -22,7 +22,7 @@ export const handleBillingBehaviorErrors = ({
}: {
billingContext: UpdateSubscriptionBillingContext;
billingPlan: BillingPlan;
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
}) => {
const { autumn: autumnBillingPlan } = billingPlan;

View File

@@ -1,12 +1,14 @@
import type {
AutumnBillingPlan,
UpdateSubscriptionBillingContext,
} from "@autumn/shared";
import {
cusProductToProduct,
productsAreSame,
RecaseError,
type UpdateSubscriptionV0Params,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
import type { AutumnBillingPlan } from "@autumn/shared";
export const handleCustomPlanErrors = ({
ctx,
@@ -17,9 +19,9 @@ export const handleCustomPlanErrors = ({
ctx: AutumnContext;
billingContext: UpdateSubscriptionBillingContext;
autumnBillingPlan: AutumnBillingPlan;
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
}) => {
if (!params.items) return;
if (!params.customize) return;
const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0];
const currentCustomerProduct = billingContext.customerProduct;

View File

@@ -1,6 +1,7 @@
import type {
AutumnBillingPlan,
UpdateSubscriptionBillingContext,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
import {
cusProductToPrices,
@@ -9,7 +10,6 @@ import {
nullish,
priceToFeature,
RecaseError,
type UpdateSubscriptionV0Params,
type UsagePriceConfig,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
@@ -22,7 +22,7 @@ const checkInputFeatureQuantitiesAreValid = ({
billingContext,
}: {
ctx: AutumnContext;
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
autumnBillingPlan: AutumnBillingPlan;
billingContext: UpdateSubscriptionBillingContext;
}) => {
@@ -66,7 +66,7 @@ export const handleFeatureQuantityErrors = ({
ctx: AutumnContext;
billingContext: UpdateSubscriptionBillingContext;
autumnBillingPlan: AutumnBillingPlan;
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
}) => {
// 1. Check if param feature IDs are valid
checkInputFeatureQuantitiesAreValid({

View File

@@ -1,3 +1,7 @@
import type {
AutumnBillingPlan,
UpdateSubscriptionBillingContext,
} from "@autumn/shared";
import {
cusProductToProduct,
isCustomerProductOneOff,
@@ -5,12 +9,10 @@ import {
isOneOffPrice,
productsAreSame,
RecaseError,
type UpdateSubscriptionV0Params,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
import { cusProductToPrices } from "@shared/utils/cusProductUtils/convertCusProduct";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
import type { AutumnBillingPlan } from "@autumn/shared";
import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition";
export const handleOneOffErrors = ({
@@ -22,7 +24,7 @@ export const handleOneOffErrors = ({
ctx: AutumnContext;
billingContext: UpdateSubscriptionBillingContext;
autumnBillingPlan: AutumnBillingPlan;
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
}) => {
const { customerProduct } = billingContext;

View File

@@ -1,7 +1,7 @@
import type {
BillingPlan,
UpdateSubscriptionBillingContext,
UpdateSubscriptionV0Params,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { handleCancelEndOfCycleErrors } from "@/internal/billing/v2/actions/updateSubscription/errors/handleCancelEndOfCycleErrors";
@@ -28,7 +28,7 @@ export const handleUpdateSubscriptionErrors = async ({
ctx: AutumnContext;
billingContext: UpdateSubscriptionBillingContext;
billingPlan: BillingPlan;
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
}) => {
const { customerProduct } = billingContext;

View File

@@ -1,14 +1,14 @@
import {
type FullCustomer,
isCusProductOnEntity,
type UpdateSubscriptionV0Params,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
export const findTargetCustomerProduct = ({
params,
fullCustomer,
}: {
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
fullCustomer: FullCustomer;
}) => {
const cusProducts = fullCustomer.customer_products;

View File

@@ -3,7 +3,7 @@ import {
type FullCusProduct,
type FullProduct,
nullish,
type UpdateSubscriptionV0Params,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { getFreeDefaultProductByGroup } from "@/internal/customers/cusProducts/cusProductUtils";
@@ -18,7 +18,7 @@ export const setupDefaultProductContext = async ({
customerProduct,
}: {
ctx: AutumnContext;
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
customerProduct: FullCusProduct;
}): Promise<FullProduct | undefined> => {
// Only fetch if cancel is requested (not null/undefined)

View File

@@ -1,12 +1,9 @@
import type {
UpdateSubscriptionBillingContext,
UpdateSubscriptionBillingContextOverride,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
import {
BillingVersion,
notNullish,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import { BillingVersion, notNullish } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { setupDefaultProductContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext";
import { setupUpdateSubscriptionProductContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext";
@@ -31,7 +28,7 @@ export const setupUpdateSubscriptionBillingContext = async ({
contextOverride = {},
}: {
ctx: AutumnContext;
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
contextOverride?: UpdateSubscriptionBillingContextOverride;
}): Promise<UpdateSubscriptionBillingContext> => {
const fullCustomer = await setupFullCustomerContext({
@@ -101,7 +98,7 @@ export const setupUpdateSubscriptionBillingContext = async ({
});
const invoiceMode = setupInvoiceModeContext({ params });
const isCustom = notNullish(params.items);
const isCustom = notNullish(params.customize);
const defaultProduct = await setupDefaultProductContext({
ctx,

View File

@@ -4,7 +4,7 @@ import {
notNullish,
RecaseError,
type UpdateSubscriptionBillingContextOverride,
type UpdateSubscriptionV0Params,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { ProductService } from "@/internal/products/ProductService";
@@ -19,7 +19,7 @@ export const setupUpdateSubscriptionProductContext = async ({
}: {
ctx: AutumnContext;
fullCustomer: FullCustomer;
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
contextOverride?: UpdateSubscriptionBillingContextOverride;
}) => {
const { productContext } = contextOverride;
@@ -59,7 +59,7 @@ export const setupUpdateSubscriptionProductContext = async ({
} = await setupCustomFullProduct({
ctx,
currentFullProduct: fullProduct,
customItems: params.items,
customizePlan: params.customize,
});
return {

View File

@@ -1,5 +1,5 @@
import type {
BillingParamsBase,
BillingParamsBaseV1,
FullCusProduct,
FullProduct,
TrialContext,
@@ -33,7 +33,7 @@ export const setupUpdateSubscriptionTrialContext = ({
customerProduct?: FullCusProduct;
currentEpochMs: number;
fullProduct: FullProduct;
params: BillingParamsBase;
params: BillingParamsBaseV1;
}): TrialContext | undefined => {
// Handle explicit free_trial param (null or value)
if (params.free_trial !== undefined) {

View File

@@ -3,7 +3,7 @@ import type {
BillingResult,
UpdateSubscriptionBillingContext,
UpdateSubscriptionBillingContextOverride,
UpdateSubscriptionV0Params,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan";
@@ -23,7 +23,7 @@ export async function updateSubscription({
contextOverride,
}: {
ctx: AutumnContext;
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
preview?: boolean;
contextOverride?: UpdateSubscriptionBillingContextOverride;
}): Promise<{

View File

@@ -1,5 +1,5 @@
import type {
BillingParamsBase,
BillingParamsBaseV1,
EntitlementWithFeature,
FeatureOptions,
Price,
@@ -12,7 +12,7 @@ export const paramsToFeatureOptions = ({
price,
entitlement,
}: {
params: BillingParamsBase;
params: BillingParamsBaseV1;
price: Price;
entitlement: EntitlementWithFeature;
}): FeatureOptions | undefined => {

View File

@@ -1,10 +1,20 @@
import { AttachParamsV0Schema, InternalError } from "@autumn/shared";
import {
AffectedResource,
ApiVersion,
AttachParamsV0Schema,
AttachParamsV1Schema,
InternalError,
} from "@autumn/shared";
import { billingActions } from "@/internal/billing/v2/actions";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { billingResultToResponse } from "../utils/billingResult/billingResultToResponse";
export const handleAttachV2 = createRoute({
body: AttachParamsV0Schema,
versionedBody: {
latest: AttachParamsV1Schema,
[ApiVersion.V1_Beta]: AttachParamsV0Schema,
},
resource: AffectedResource.Attach,
lock:
process.env.NODE_ENV !== "development"
? {

View File

@@ -1,11 +1,20 @@
import { AttachParamsV0Schema } from "@autumn/shared";
import {
AffectedResource,
ApiVersion,
AttachParamsV0Schema,
AttachParamsV1Schema,
} from "@autumn/shared";
import { billingActions } from "@/internal/billing/v2/actions";
import { billingPlanToChanges } from "@/internal/billing/v2/utils/billingPlanToChanges.js";
import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
export const handlePreviewAttach = createRoute({
body: AttachParamsV0Schema,
versionedBody: {
latest: AttachParamsV1Schema,
[ApiVersion.V1_Beta]: AttachParamsV0Schema,
},
resource: AffectedResource.Attach,
lock:
process.env.NODE_ENV !== "development"
? {

View File

@@ -1,10 +1,19 @@
import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared";
import {
AffectedResource,
ApiVersion,
UpdateSubscriptionV0ParamsSchema,
UpdateSubscriptionV1ParamsSchema,
} from "@autumn/shared";
import { billingActions } from "@/internal/billing/v2/actions";
import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
export const handlePreviewUpdateSubscription = createRoute({
body: UpdateSubscriptionV0ParamsSchema,
versionedBody: {
latest: UpdateSubscriptionV1ParamsSchema,
[ApiVersion.V1_Beta]: UpdateSubscriptionV0ParamsSchema,
},
resource: AffectedResource.ApiSubscriptionUpdate,
handler: async (c) => {
const ctx = c.get("ctx");
const body = c.req.valid("json");

View File

@@ -1,13 +1,20 @@
import {
AffectedResource,
ApiVersion,
InternalError,
UpdateSubscriptionV0ParamsSchema,
UpdateSubscriptionV1ParamsSchema,
} from "@autumn/shared";
import { billingActions } from "@/internal/billing/v2/actions";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { billingResultToResponse } from "../utils/billingResult/billingResultToResponse";
export const handleUpdateSubscription = createRoute({
body: UpdateSubscriptionV0ParamsSchema,
versionedBody: {
latest: UpdateSubscriptionV1ParamsSchema,
[ApiVersion.V1_Beta]: UpdateSubscriptionV0ParamsSchema,
},
resource: AffectedResource.ApiSubscriptionUpdate,
lock:
process.env.NODE_ENV !== "development"
? {

View File

@@ -1,5 +1,4 @@
import type { UpdateSubscriptionV0Params } from "@shared/api/billing/updateSubscription/updateSubscriptionV0Params";
import type { CancelAction } from "@autumn/shared";
import type { CancelAction, UpdateSubscriptionV1Params } from "@autumn/shared";
/**
* Setup cancel action from params
@@ -14,7 +13,7 @@ import type { CancelAction } from "@autumn/shared";
export const setupCancelAction = ({
params,
}: {
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV1Params;
}): CancelAction | undefined => {
return params.cancel_action;
};

View File

@@ -1,18 +1,24 @@
import type { FullProduct, ProductItem } from "@autumn/shared";
import {
type CustomizePlanV1,
customizePlanV1ToV0,
type FullProduct,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { getEntsWithFeature } from "@/internal/products/entitlements/entitlementUtils";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems";
export const setupCustomFullProduct = async ({
ctx,
customItems,
// customItems,
currentFullProduct,
customizePlan,
}: {
ctx: AutumnContext;
customItems?: ProductItem[];
// customItems?: ProductItem[];
currentFullProduct: FullProduct;
customizePlan?: CustomizePlanV1;
}) => {
if (!customItems) {
if (!customizePlan) {
return {
fullProduct: currentFullProduct,
customPrices: [],
@@ -20,6 +26,8 @@ export const setupCustomFullProduct = async ({
};
}
// Customize plan -> custom items
const { db, logger, features } = ctx;
const { prices: currentPrices, entitlements: currentEntitlements } =
@@ -30,7 +38,11 @@ export const setupCustomFullProduct = async ({
db,
curPrices: currentPrices,
curEnts: currentEntitlements,
newItems: customItems,
newItems: customizePlanV1ToV0({
ctx,
customizePlanV1: customizePlan,
fullProduct: currentFullProduct,
}),
features,
product: currentFullProduct,
logger,

View File

@@ -1,6 +1,6 @@
import {
type BillingContextOverride,
type BillingParamsBase,
type BillingParamsBaseV1,
cusProductToConvertedFeatureOptions,
type FeatureOptions,
type FullCusProduct,
@@ -25,7 +25,7 @@ export const setupFeatureQuantitiesContext = ({
contextOverride = {},
}: {
ctx: AutumnContext;
featureQuantitiesParams: BillingParamsBase;
featureQuantitiesParams: BillingParamsBaseV1;
fullProduct: FullProduct;
currentCustomerProduct?: FullCusProduct;
initializeUndefinedQuantities?: boolean;

View File

@@ -1,4 +1,4 @@
import type { BillingParamsBase } from "@autumn/shared";
import type { BillingParamsBaseV1 } from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import { CusService } from "@server/internal/customers/CusService";
@@ -7,7 +7,7 @@ export const setupFullCustomerContext = async ({
params,
}: {
ctx: AutumnContext;
params: BillingParamsBase;
params: BillingParamsBaseV1;
}) => {
const { db, org, env } = ctx;
const { customer_id: customerId } = params;

View File

@@ -1,12 +1,12 @@
import type {
AttachParamsV0,
UpdateSubscriptionV0Params,
AttachParamsV1,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
export const setupInvoiceModeContext = ({
params,
}: {
params: UpdateSubscriptionV0Params | AttachParamsV0;
params: UpdateSubscriptionV1Params | AttachParamsV1;
}) => {
if (params?.invoice !== true) {
return undefined;

View File

@@ -1,11 +1,14 @@
import type { BillingContextOverride, BillingParamsBase } from "@autumn/shared";
import type {
BillingContextOverride,
BillingParamsBaseV1,
} from "@autumn/shared";
import type { TransitionConfig } from "@models/billingModels/context/transitionConfig";
export const setupTransitionConfigs = ({
params,
contextOverride = {},
}: {
params: BillingParamsBase;
params: BillingParamsBaseV1;
contextOverride?: BillingContextOverride;
}): TransitionConfig[] => {
if (contextOverride.transitionConfigs) {

View File

@@ -4,10 +4,10 @@ import {
isCustomerProductTrialing,
isProductPaidAndRecurring,
} from "@autumn/shared";
import type { FreeTrialParamsV0 } from "@shared/api/common/freeTrial/freeTrialParamsV0";
import type { FreeTrialParamsV1 } from "@shared/api/common/freeTrial/freeTrialParamsV1";
import type Stripe from "stripe";
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import { initFreeTrial } from "@/internal/products/free-trials/initFreeTrial";
import { initFreeTrialFromParamsV1 } from "@/internal/products/free-trials/initFreeTrialFromParamsV1";
/**
* Handles explicit free_trial parameter passed to attach/update subscription.
@@ -22,7 +22,7 @@ export const handleFreeTrialParam = ({
fullProduct,
currentEpochMs,
}: {
freeTrialParams: FreeTrialParamsV0 | null;
freeTrialParams: FreeTrialParamsV1 | null;
stripeSubscription?: Stripe.Subscription;
customerProduct?: FullCusProduct;
fullProduct: FullProduct;
@@ -48,7 +48,7 @@ export const handleFreeTrialParam = ({
}
// free_trial: { length, duration } → Fresh trial
const dbFreeTrial = initFreeTrial({
const dbFreeTrial = initFreeTrialFromParamsV1({
freeTrialParams,
internalProductId: fullProduct.internal_id,
isCustom: true,

View File

@@ -1,6 +1,6 @@
import {
type AttachBillingContext,
type AttachParamsV0,
type AttachParamsV1,
type BillingPlan,
type Checkout,
CheckoutAction,
@@ -29,7 +29,7 @@ export async function billingPlanToAutumnCheckout({
billingContext,
}: {
ctx: AutumnContext;
params: AttachParamsV0;
params: AttachParamsV1;
billingContext: AttachBillingContext;
billingPlan: BillingPlan;
}): Promise<{ checkout: Checkout; checkoutUrl: string }> {

View File

@@ -1,5 +1,5 @@
import {
type AttachParamsV0,
type AttachParamsV1,
type Checkout,
CheckoutAction,
CheckoutStatus,
@@ -43,7 +43,7 @@ export const handleConfirmCheckout = createRoute({
});
}
const params = checkout.params as AttachParamsV0;
const params = checkout.params as AttachParamsV1;
try {
// Execute attach (not preview mode)

View File

@@ -1,5 +1,5 @@
import {
type AttachParamsV0,
type AttachParamsV1,
type Checkout,
CheckoutAction,
ErrCode,
@@ -31,7 +31,7 @@ export const handleGetCheckout = createRoute({
});
}
const params = checkout.params as AttachParamsV0;
const params = checkout.params as AttachParamsV1;
// Re-run attach in preview mode to get current billing plan
const { billingContext, billingPlan } = await billingActions.attach({

View File

@@ -1,5 +1,5 @@
import {
type AttachParamsV0,
type AttachParamsV1,
type Checkout,
CheckoutAction,
ErrCode,
@@ -44,10 +44,10 @@ export const handlePreviewCheckout = createRoute({
});
}
const originalParams = checkout.params as AttachParamsV0;
const originalParams = checkout.params as AttachParamsV1;
// Merge provided options with original params
const params: AttachParamsV0 = {
const params: AttachParamsV1 = {
...originalParams,
options: body.options,
};

View File

@@ -1,4 +1,4 @@
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
import type { UpdateSubscriptionV1Params } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan";
import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors";
@@ -23,7 +23,7 @@ export const handleCancelV2 = createRoute({
prorate: bodyProrate = true,
} = await c.req.json();
const updateSubscriptionBody: UpdateSubscriptionV0Params = {
const updateSubscriptionBody: UpdateSubscriptionV1Params = {
customer_id,
product_id,
entity_id,

View File

@@ -0,0 +1,25 @@
import type { FreeTrial } from "@autumn/shared";
import type { FreeTrialParamsV1 } from "@shared/api/common/freeTrial/freeTrialParamsV1";
import { generateId } from "@/utils/genUtils";
export const initFreeTrialFromParamsV1 = ({
freeTrialParams,
internalProductId,
isCustom = false,
}: {
freeTrialParams: FreeTrialParamsV1;
internalProductId: string;
isCustom?: boolean;
}): FreeTrial => {
return {
id: generateId("ft"),
created_at: Date.now(),
internal_product_id: internalProductId,
is_custom: isCustom,
duration: freeTrialParams.duration_type,
length: freeTrialParams.duration_length,
card_required: freeTrialParams.card_required,
unique_fingerprint: true,
};
};

View File

@@ -0,0 +1,396 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3, AttachParamsV1Input } from "@autumn/shared";
import {
expectCustomerFeatureCorrect,
expectCustomerFeatureExists,
} from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { isPriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js";
test.concurrent(`${chalk.yellowBright("v2-customize attach: both customize.price + customize.items")}`, async () => {
const customerId = "v2-attach-customize-both";
const base = products.base({
id: "base",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
product_id: base.id,
redirect_mode: "if_required",
customize: {
price: itemsV2.monthlyPrice({ amount: 30 }),
items: [itemsV2.monthlyWords({ included: 250 }), itemsV2.dashboard()],
},
};
const preview =
await autumnV2.billing.previewAttach<AttachParamsV1Input>(params);
expect(preview.total).toBe(30);
await autumnV2.billing.attach<AttachParamsV1Input>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: base.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
includedUsage: 250,
balance: 250,
usage: 0,
});
await expectCustomerFeatureExists({
customer,
featureId: TestFeature.Dashboard,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 30,
});
});
test.concurrent(`${chalk.yellowBright("v2-customize attach: only customize.price")}`, async () => {
const customerId = "v2-attach-customize-only-price";
const base = products.base({
id: "base",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
product_id: base.id,
redirect_mode: "if_required",
customize: {
price: itemsV2.monthlyPrice({ amount: 25 }),
},
};
const preview =
await autumnV2.billing.previewAttach<AttachParamsV1Input>(params);
expect(preview.total).toBe(25);
await autumnV2.billing.attach<AttachParamsV1Input>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: base.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 25,
});
});
test.concurrent(`${chalk.yellowBright("v2-customize attach: only customize.items")}`, async () => {
const customerId = "v2-attach-customize-only-items";
const pro = products.base({
id: "pro",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice({ price: 20 }),
],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
customize: {
// price: null,
items: [itemsV2.monthlyMessages({ included: 220 })],
},
};
const preview =
await autumnV2.billing.previewAttach<AttachParamsV1Input>(params);
expect(preview.total).toBe(20);
await autumnV2.billing.attach<AttachParamsV1Input>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 220,
balance: 220,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
});
// Verify that the original product price is still attached (price: null means keep original)
const customerProduct = customer.products.find((p) => p.id === pro.id);
const priceItem = customerProduct?.items?.find(isPriceItem);
expect(priceItem).toBeDefined();
expect(priceItem?.price).toBe(20);
});
test.concurrent(`${chalk.yellowBright("v2-customize attach: price null makes product free")}`, async () => {
const customerId = "v2-attach-customize-price-null-free";
const pro = products.base({
id: "pro",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyWords({ includedUsage: 50 }),
items.monthlyPrice({ price: 20 }),
],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
customize: {
price: null,
items: [itemsV2.monthlyMessages({ included: 200 })],
},
};
const preview =
await autumnV2.billing.previewAttach<AttachParamsV1Input>(params);
expect(preview.total).toBe(0);
await autumnV2.billing.attach<AttachParamsV1Input>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
// Verify the customized feature is correct
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 200,
balance: 200,
usage: 0,
});
// Verify no invoice was created (product is free)
await expectCustomerInvoiceCorrect({
customer,
count: 0,
});
// Verify no price item exists on the customer product
const customerProduct = customer.products.find((p) => p.id === pro.id);
const priceItem = customerProduct?.items?.find(isPriceItem);
expect(priceItem).toBeUndefined();
});
test.concurrent(`${chalk.yellowBright("v2-customize attach: plan item v1 prepaid mapping")}`, async () => {
const customerId = "v2-attach-customize-prepaid-map";
const base = products.base({
id: "base",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
product_id: base.id,
redirect_mode: "if_required",
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
customize: {
price: null,
items: [itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 })],
},
};
const preview =
await autumnV2.billing.previewAttach<AttachParamsV1Input>(params);
expect(preview.total).toBe(10);
await autumnV2.billing.attach<AttachParamsV1Input>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 10,
});
});
test.concurrent(`${chalk.yellowBright("v2-customize attach: plan item v1 multi-feature mapping")}`, async () => {
const customerId = "v2-attach-customize-multi-feature-map";
const base = products.base({
id: "base",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
product_id: base.id,
redirect_mode: "if_required",
customize: {
price: itemsV2.monthlyPrice({ amount: 40 }),
items: [
itemsV2.monthlyMessages({ included: 300 }),
itemsV2.monthlyWords({ included: 150 }),
],
},
};
await autumnV2.billing.attach<AttachParamsV1Input>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 300,
balance: 300,
usage: 0,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
includedUsage: 150,
balance: 150,
usage: 0,
});
});
test.concurrent(`${chalk.yellowBright("v2-customize attach: paid feature mix (consumable + prepaid + allocated)")}`, async () => {
const customerId = "v2-attach-customize-paid-feature-mix";
const base = products.base({
id: "base",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
product_id: base.id,
redirect_mode: "if_required",
options: [{ feature_id: TestFeature.Words, quantity: 500 }],
customize: {
price: itemsV2.monthlyPrice({ amount: 40 }),
items: [
itemsV2.consumableMessages({ amount: 1 }),
itemsV2.prepaidWords({ amount: 15, billingUnits: 100, included: 200 }),
itemsV2.allocatedUsers({ amount: 10, included: 3 }),
],
},
};
const preview =
await autumnV2.billing.previewAttach<AttachParamsV1Input>(params);
// Base ($40) + prepaid words ($15 for 100 units) + allocated users at included quantity
expect(preview.total).toBe(85);
await autumnV2.billing.attach<AttachParamsV1Input>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
includedUsage: 500,
balance: 500,
usage: 0,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: 3,
balance: 3,
usage: 0,
});
await expectCustomerFeatureExists({
customer,
featureId: TestFeature.Messages,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: preview.total,
});
});

View File

@@ -0,0 +1,153 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3, AttachParamsV1Input } from "@autumn/shared";
import { FreeTrialDuration, ms } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addMonths } from "date-fns";
test.concurrent(`${chalk.yellowBright("v2-free-trial attach: set trial with v1 free_trial params")}`, async () => {
const customerId = "v2-attach-free-trial-set";
const pro = products.base({
id: "pro",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice({ price: 20 }),
],
});
const { autumnV1, autumnV2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
free_trial: {
duration_length: 7,
duration_type: FreeTrialDuration.Day,
},
};
const preview =
await autumnV2.billing.previewAttach<AttachParamsV1Input>(params);
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(7),
total: 20,
});
await autumnV2.billing.attach<AttachParamsV1Input>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(7),
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
});
test.concurrent(`${chalk.yellowBright("v2-free-trial attach: remove product trial with free_trial null")}`, async () => {
const customerId = "v2-attach-free-trial-null";
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [items.monthlyMessages({ includedUsage: 100 })],
trialDays: 14,
});
const { autumnV1, autumnV2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
product_id: proTrial.id,
redirect_mode: "if_required",
free_trial: null,
};
const preview =
await autumnV2.billing.previewAttach<AttachParamsV1Input>(params);
expect(preview.total).toBe(20);
await autumnV2.billing.attach<AttachParamsV1Input>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotTrialing({
customer,
productId: proTrial.id,
nowMs: advancedTo,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 20,
});
});
test.concurrent(`${chalk.yellowBright("v2-free-trial attach: month-based v1 free_trial params")}`, async () => {
const customerId = "v2-attach-free-trial-month";
const pro = products.base({
id: "pro",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice({ price: 20 }),
],
});
const { autumnV1, autumnV2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
free_trial: {
duration_length: 1,
duration_type: FreeTrialDuration.Month,
},
};
await autumnV2.billing.attach<AttachParamsV1Input>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer,
productId: pro.id,
trialEndsAt: addMonths(advancedTo, 1).getTime(),
toleranceMs: ms.hours(2),
});
});

View File

@@ -0,0 +1,265 @@
import { expect, test } from "bun:test";
import type {
ApiCustomerV3,
UpdateSubscriptionV1ParamsInput,
} from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
test.concurrent(`${chalk.yellowBright("v2-customize update: both customize.price + customize.items")}`, async () => {
const customerId = "v2-update-customize-both";
const pro = products.base({
id: "pro",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice({ price: 20 }),
],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const params: UpdateSubscriptionV1ParamsInput = {
customer_id: customerId,
product_id: pro.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 30 }),
items: [itemsV2.monthlyWords({ included: 200 })],
},
};
const preview =
await autumnV2.subscriptions.previewUpdate<UpdateSubscriptionV1ParamsInput>(
params,
);
expect(preview.total).toBe(10);
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
includedUsage: 200,
balance: 200,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 10,
});
});
test.concurrent(`${chalk.yellowBright("v2-customize update: only customize.price")}`, async () => {
const customerId = "v2-update-customize-only-price";
const pro = products.base({
id: "pro",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice({ price: 20 }),
],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const params: UpdateSubscriptionV1ParamsInput = {
customer_id: customerId,
product_id: pro.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 24 }),
},
};
const preview =
await autumnV2.subscriptions.previewUpdate<UpdateSubscriptionV1ParamsInput>(
params,
);
expect(preview.total).toBe(4);
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 4,
});
});
test.concurrent(`${chalk.yellowBright("v2-customize update: only customize.items")}`, async () => {
const customerId = "v2-update-customize-only-items";
const pro = products.base({
id: "pro",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice({ price: 20 }),
],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const params: UpdateSubscriptionV1ParamsInput = {
customer_id: customerId,
product_id: pro.id,
customize: {
price: null,
items: [itemsV2.monthlyMessages({ included: 180 })],
},
};
const preview =
await autumnV2.subscriptions.previewUpdate<UpdateSubscriptionV1ParamsInput>(
params,
);
expect(preview.total).toBe(-20);
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 180,
balance: 180,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: -20,
});
});
test.concurrent(`${chalk.yellowBright("v2-customize update: plan item v1 prepaid mapping")}`, async () => {
const customerId = "v2-update-customize-prepaid-map";
const pro = products.base({
id: "pro",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice({ price: 20 }),
],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const params: UpdateSubscriptionV1ParamsInput = {
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
customize: {
price: null,
items: [itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 })],
},
};
const preview =
await autumnV2.subscriptions.previewUpdate<UpdateSubscriptionV1ParamsInput>(
params,
);
expect(preview.total).toBe(-10);
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: -10,
});
});
test.concurrent(`${chalk.yellowBright("v2-customize update: plan item v1 multi-feature mapping")}`, async () => {
const customerId = "v2-update-customize-multi-feature-map";
const pro = products.base({
id: "pro",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice({ price: 20 }),
],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const params: UpdateSubscriptionV1ParamsInput = {
customer_id: customerId,
product_id: pro.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 40 }),
items: [
itemsV2.monthlyMessages({ included: 300 }),
itemsV2.monthlyWords({ included: 150 }),
],
},
};
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 300,
balance: 300,
usage: 0,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
includedUsage: 150,
balance: 150,
usage: 0,
});
});

View File

@@ -0,0 +1,158 @@
import { expect, test } from "bun:test";
import type {
ApiCustomerV3,
UpdateSubscriptionV1ParamsInput,
} from "@autumn/shared";
import { FreeTrialDuration, ms } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addMonths } from "date-fns";
test.concurrent(`${chalk.yellowBright("v2-free-trial update: set trial with v1 free_trial params")}`, async () => {
const customerId = "v2-update-free-trial-set";
const pro = products.base({
id: "pro",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice({ price: 20 }),
],
});
const { autumnV1, autumnV2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", testClock: true }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const params: UpdateSubscriptionV1ParamsInput = {
customer_id: customerId,
product_id: pro.id,
free_trial: {
duration_length: 7,
duration_type: FreeTrialDuration.Day,
},
};
const preview =
await autumnV2.subscriptions.previewUpdate<UpdateSubscriptionV1ParamsInput>(
params,
);
expect(preview.total).toBe(-20);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(7),
total: 20,
});
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(7),
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: -20,
});
});
test.concurrent(`${chalk.yellowBright("v2-free-trial update: remove trial with free_trial null")}`, async () => {
const customerId = "v2-update-free-trial-remove";
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [items.monthlyMessages({ includedUsage: 100 })],
trialDays: 14,
});
const { autumnV1, autumnV2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", testClock: true }),
s.products({ list: [proTrial] }),
],
actions: [
s.billing.attach({ productId: proTrial.id }),
s.advanceTestClock({ days: 3 }),
],
});
const params: UpdateSubscriptionV1ParamsInput = {
customer_id: customerId,
product_id: proTrial.id,
free_trial: null,
};
const preview =
await autumnV2.subscriptions.previewUpdate<UpdateSubscriptionV1ParamsInput>(
params,
);
expect(preview.total).toBe(20);
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotTrialing({
customer,
productId: proTrial.id,
nowMs: advancedTo,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 20,
});
});
test.concurrent(`${chalk.yellowBright("v2-free-trial update: replace active trial with month trial")}`, async () => {
const customerId = "v2-update-free-trial-replace";
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [items.monthlyMessages({ includedUsage: 100 })],
trialDays: 7,
});
const { autumnV1, autumnV2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", testClock: true }),
s.products({ list: [proTrial] }),
],
actions: [s.billing.attach({ productId: proTrial.id })],
});
const params: UpdateSubscriptionV1ParamsInput = {
customer_id: customerId,
product_id: proTrial.id,
free_trial: {
duration_length: 1,
duration_type: FreeTrialDuration.Month,
},
};
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer,
productId: proTrial.id,
trialEndsAt: addMonths(advancedTo, 1).getTime(),
toleranceMs: ms.hours(2),
});
});

View File

@@ -5,7 +5,6 @@ import {
ApiVersion,
formatMs,
} from "@autumn/shared";
import type { Customer } from "autumn-js";
import { AutumnInt } from "@/external/autumn/autumnCli";
const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 });
@@ -16,7 +15,7 @@ export const expectCustomerFeatureExists = async ({
featureId,
}: {
customerId?: string;
customer?: Customer | ApiEntityV0;
customer?: ApiCustomerV3 | ApiEntityV0;
featureId: string;
}) => {
const customer = providedCustomer

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { ExistingRollover } from "@autumn/shared";
import { type ExistingRollover, RolloverExpiryDurationType } from "@autumn/shared";
import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements";
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
import chalk from "chalk";
@@ -16,6 +16,7 @@ describe(chalk.yellowBright("applyExistingRollovers"), () => {
featureName: "Words",
allowance: 5000,
balance: 5000,
rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 },
});
const newCusProduct = customerProducts.create({

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { ExistingRollover } from "@autumn/shared";
import { type ExistingRollover, RolloverExpiryDurationType } from "@autumn/shared";
import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements";
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
import chalk from "chalk";
@@ -19,6 +19,7 @@ describe(
featureName: "Words",
allowance: 5000,
balance: 5000,
rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 },
});
const newCusProduct = customerProducts.create({

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { ExistingRollover } from "@autumn/shared";
import { type ExistingRollover, RolloverExpiryDurationType } from "@autumn/shared";
import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements";
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
import chalk from "chalk";
@@ -15,6 +15,7 @@ describe(
featureName: "Feature A",
allowance: 100,
balance: 100,
rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 },
});
const cusEntB = customerEntitlements.create({
@@ -23,6 +24,7 @@ describe(
featureName: "Feature B",
allowance: 200,
balance: 200,
rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 },
});
const newCusProduct = customerProducts.create({

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { ExistingRollover } from "@autumn/shared";
import { type ExistingRollover, RolloverExpiryDurationType } from "@autumn/shared";
import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements";
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
import chalk from "chalk";
@@ -18,6 +18,7 @@ describe(
featureName: "Words",
allowance: 100,
balance: 100,
rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 },
});
const cusEntSecond = customerEntitlements.create({
@@ -27,6 +28,7 @@ describe(
featureName: "Words",
allowance: 200,
balance: 200,
rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 },
});
const newCusProduct = customerProducts.create({

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { ExistingRollover } from "@autumn/shared";
import { type ExistingRollover, RolloverExpiryDurationType } from "@autumn/shared";
import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements";
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
import chalk from "chalk";
@@ -19,6 +19,7 @@ describe(
featureName: "Seats",
allowance: 10,
balance: 10,
rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 },
});
const newCusProduct = customerProducts.create({

View File

@@ -45,7 +45,7 @@ describe(
customerPrices: [customerPrice],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Usage should be 150: 100 from included + 50 from overage
// Formula: usage = includedUsage - balance = 100 - (-50) = 150
@@ -83,7 +83,7 @@ describe(
customerPrices: [customerPrice],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Usage should be 70: allowance 100 - balance 30 = 70
expect(existingUsages[internalFeatureId]).toBeDefined();
@@ -121,7 +121,7 @@ describe(
customerPrices: [customerPrice],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Usage should be 200: all from overage (0 - (-200) = 200)
expect(existingUsages[internalFeatureId]).toBeDefined();
@@ -158,7 +158,7 @@ describe(
customerPrices: [customerPrice],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Usage should be 100: exactly at the limit
expect(existingUsages[internalFeatureId]).toBeDefined();
@@ -201,7 +201,7 @@ describe(
customerPrices: [customerPrice],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Usage should be 3: 5 included - 2 remaining = 3 used
expect(existingUsages[internalFeatureId]).toBeDefined();
@@ -238,7 +238,7 @@ describe(
customerPrices: [customerPrice],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Usage should be 5: 3 - (-2) = 5 total seats used
expect(existingUsages[internalFeatureId]).toBeDefined();
@@ -275,7 +275,7 @@ describe(
customerPrices: [customerPrice],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Usage should be 10: 0 - (-10) = 10
expect(existingUsages[internalFeatureId]).toBeDefined();
@@ -320,7 +320,7 @@ describe(
customerPrices: [customerPrice],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// For prepaid, usage = purchasedQuantity - balance
// purchasedQuantity from 10 billing units = 1000
@@ -362,7 +362,7 @@ describe(
customerPrices: [customerPrice],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
expect(existingUsages[internalFeatureId]).toBeDefined();
});
@@ -399,7 +399,7 @@ describe(
customerPrices: [customerPrice],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
expect(existingUsages[internalFeatureId]).toBeDefined();
// Overage should be captured
@@ -471,7 +471,7 @@ describe(
],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Messages: 100 - (-20) = 120 used
expect(existingUsages[messagesFeatureId]).toBeDefined();
@@ -550,7 +550,7 @@ describe(
],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Messages: 50 - (-150) = 200 used
expect(existingUsages[messagesFeatureId]).toBeDefined();
@@ -601,7 +601,7 @@ describe(
customerPrices: [customerPrice],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Usage should be 0
expect(existingUsages[internalFeatureId]).toBeDefined();
@@ -638,7 +638,7 @@ describe(
customerPrices: [customerPrice],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Usage should be 10100: 100 - (-10000) = 10100
expect(existingUsages[internalFeatureId]).toBeDefined();
@@ -675,7 +675,7 @@ describe(
customerPrices: [customerPrice],
});
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Usage should be 150.5
expect(existingUsages[internalFeatureId]).toBeDefined();

View File

@@ -37,7 +37,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => {
});
// Act
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Assert: Total usage should be 40 (20 + 20)
expect(existingUsages[internalFeatureId]).toBeDefined();
@@ -78,7 +78,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => {
});
// Act
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Assert
expect(existingUsages[internalFeatureId]).toBeDefined();
@@ -130,7 +130,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => {
});
// Act
const existingUsages = cusProductToExistingUsages({ cusProduct });
const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true });
// Assert: Usage should be 20 (allowance 100 - balance 80)
// NOT 50 (allowance 100 + rollover 30 - balance 80)

View File

@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test";
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
import type { UpdateSubscriptionV1Params } from "@autumn/shared";
import { contexts } from "@tests/utils/fixtures/db/contexts";
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
import { entitlements } from "@tests/utils/fixtures/db/entitlements";
import { features } from "@tests/utils/fixtures/db/features";
import { prices } from "@tests/utils/fixtures/db/prices";
import { products } from "@tests/utils/fixtures/db/products";
@@ -18,12 +19,23 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({ prices: [price] });
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
@@ -35,7 +47,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
customerPrices: [prices.createCustomer({ price })],
});
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
// No options provided
@@ -61,15 +73,26 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({ prices: [price] });
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 50 }],
@@ -95,12 +118,23 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({ prices: [price] });
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
@@ -111,7 +145,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
],
});
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 200 }],
@@ -147,21 +181,44 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Storage",
});
const creditsEnt = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const seatsEnt = entitlements.create({
id: "ent_seats",
featureId: "seats",
featureName: "Seats",
allowance: 0,
});
const storageEnt = entitlements.create({
id: "ent_storage",
featureId: "storage",
featureName: "Storage",
allowance: 0,
});
const creditsPrice = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const seatsPrice = prices.createPrepaid({
id: "price_seats",
featureId: "seats",
entitlementId: "ent_seats",
});
const storagePrice = prices.createPrepaid({
id: "price_storage",
featureId: "storage",
entitlementId: "ent_storage",
});
const fullProduct = products.createFull({
prices: [creditsPrice, seatsPrice, storagePrice],
entitlements: [creditsEnt, seatsEnt, storageEnt],
});
const cusProduct = customerProducts.create({
@@ -189,7 +246,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
],
});
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "seats", quantity: 10 }], // Only updating seats
@@ -225,16 +282,27 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 100, // Billing in units of 100
});
const fullProduct = products.createFull({ prices: [price] });
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 150 }], // Should round up to 200
@@ -261,16 +329,27 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 50,
});
const fullProduct = products.createFull({ prices: [price] });
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple
@@ -296,16 +375,27 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 1000,
});
const fullProduct = products.createFull({ prices: [price] });
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 1 }], // Should round to 1000
@@ -334,10 +424,18 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
// Old price: billing units of 100
const oldPrice = prices.createPrepaid({
id: "price_credits_old",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 100,
});
@@ -345,10 +443,14 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
const newPrice = prices.createPrepaid({
id: "price_credits_new",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 250,
});
const fullProduct = products.createFull({ prices: [newPrice] });
const fullProduct = products.createFull({
prices: [newPrice],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
@@ -361,7 +463,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
customerPrices: [prices.createCustomer({ price: oldPrice })],
});
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
// No options - should inherit from current
@@ -393,19 +495,31 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const oldPrice = prices.createPrepaid({
id: "price_credits_old",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 100,
});
const newPrice = prices.createPrepaid({
id: "price_credits_new",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 250,
});
const fullProduct = products.createFull({ prices: [newPrice] });
const fullProduct = products.createFull({
prices: [newPrice],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
@@ -418,7 +532,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
customerPrices: [prices.createCustomer({ price: oldPrice })],
});
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
@@ -447,13 +561,24 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const newPrice = prices.createPrepaid({
id: "price_credits_new",
featureId: "credits",
entitlementId: "ent_credits",
billingUnits: 250,
});
const fullProduct = products.createFull({ prices: [newPrice] });
const fullProduct = products.createFull({
prices: [newPrice],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
@@ -466,7 +591,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
// No customerPrices - can't interpret stored quantity
});
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
@@ -493,19 +618,28 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const fixedPrice = prices.createFixed({ id: "price_fixed" });
const prepaidPrice = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({
prices: [fixedPrice, prepaidPrice],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 50 }],
@@ -528,10 +662,10 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
describe("edge cases", () => {
test("empty prices array returns empty array", () => {
const fullProduct = products.createFull({ prices: [] });
const fullProduct = products.createFull({ prices: [], entitlements: [] });
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
@@ -548,21 +682,25 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
expect(result).toHaveLength(0);
});
test("throws error when feature not found for price", () => {
test("throws error when entitlement not found for price", () => {
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({ prices: [price] });
// No entitlements provided - entitlement won't be found
const fullProduct = products.createFull({
prices: [price],
entitlements: [],
});
const cusProduct = customerProducts.create({ options: [] });
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
// Empty features array - feature won't be found
const ctx = contexts.create({ features: [] });
expect(() =>
@@ -572,7 +710,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
fullProduct,
currentCustomerProduct: cusProduct,
}),
).toThrow("Feature not found for price");
).toThrow("Entitlement not found for price");
});
test("neither current nor new has quantity → feature not included", () => {
@@ -581,15 +719,26 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({ prices: [price] });
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({ options: [] }); // No current options
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
// No options in params either
@@ -614,12 +763,23 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({ prices: [price] });
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
@@ -631,7 +791,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
customerPrices: [prices.createCustomer({ price })],
});
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [], // Explicitly empty
@@ -657,12 +817,23 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({ prices: [price] });
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
@@ -674,7 +845,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
customerPrices: [prices.createCustomer({ price })],
});
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
options: [{ feature_id: "credits", quantity: 0 }],
@@ -700,13 +871,25 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Credits",
});
const entitlement = entitlements.create({
id: "ent_credits",
featureId: "credits",
internalFeatureId: "internal_credits_v2",
featureName: "Credits",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_credits",
featureId: "credits",
internalFeatureId: "internal_credits_v2",
entitlementId: "ent_credits",
});
const fullProduct = products.createFull({ prices: [price] });
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
@@ -718,7 +901,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
customerPrices: [prices.createCustomer({ price })],
});
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
@@ -742,12 +925,23 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
name: "Seats",
});
const entitlement = entitlements.create({
id: "ent_seats",
featureId: "seats",
featureName: "Seats",
allowance: 0,
});
const price = prices.createPrepaid({
id: "price_seats",
featureId: "seats",
entitlementId: "ent_seats",
});
const fullProduct = products.createFull({ prices: [price] });
const fullProduct = products.createFull({
prices: [price],
entitlements: [entitlement],
});
const cusProduct = customerProducts.create({
options: [
{
@@ -759,7 +953,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
customerPrices: [prices.createCustomer({ price })],
});
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
// No options provided - should carry over from current

View File

@@ -180,15 +180,15 @@ export const createCustomerPricesForProduct = ({
/**
* Gets all stripe price IDs from a product helper result.
* Entity-level products use stripeEmptyPriceId for consumable prices.
* Note: As of current implementation, withEntity is hardcoded to false in customerProductToStripeItemSpecs,
* so consumable prices always use the regular (non-empty) price ID.
*/
export const getStripePriceIds = (
product: ReturnType<typeof createProductWithAllPriceTypes>,
{ isEntityLevel = false }: { isEntityLevel?: boolean } = {},
{ isEntityLevel: _isEntityLevel = false }: { isEntityLevel?: boolean } = {},
): string[] => {
const consumablePriceId = isEntityLevel
? `stripe_${product.product.id}_consumable_empty`
: `stripe_${product.product.id}_consumable`;
// withEntity is hardcoded to false, so always use regular consumable price
const consumablePriceId = `stripe_${product.product.id}_consumable`;
return [
`stripe_${product.product.id}_fixed`,
@@ -232,21 +232,16 @@ export const expectPhaseItems = (
export const getExpectedPhaseItems = (
product: ReturnType<typeof createProductWithAllPriceTypes>,
{
isEntityLevel = false,
isEntityLevel: _isEntityLevel = false,
fixedQuantityMultiplier = 1,
}: { isEntityLevel?: boolean; fixedQuantityMultiplier?: number } = {},
): ExpectedPhaseItem[] => {
const productId = product.product.id;
const { expectedQuantities } = product;
const consumablePriceId = isEntityLevel
? `stripe_${productId}_consumable_empty`
: `stripe_${productId}_consumable`;
// Entity consumable uses empty price with quantity 0
const consumableQuantity = isEntityLevel
? expectedQuantities.consumableEntity
: expectedQuantities.consumable;
// withEntity is hardcoded to false, so always use regular consumable price
const consumablePriceId = `stripe_${productId}_consumable`;
const consumableQuantity = expectedQuantities.consumable;
return [
{
@@ -298,24 +293,19 @@ export type ExpectedSubscriptionItemUpdate = {
* Gets expected subscription item updates for creating a new product.
* Uses the expectedQuantities from the product helper.
*
* @param isEntityLevel - If true, uses stripeEmptyPriceId for consumable (quantity 0)
* Note: As of current implementation, withEntity is hardcoded to false in customerProductToStripeItemSpecs,
* so consumable prices always use the regular (non-empty) price ID.
*/
export const getExpectedNewProductItems = (
product: ReturnType<typeof createProductWithAllPriceTypes>,
{ isEntityLevel = false }: { isEntityLevel?: boolean } = {},
{ isEntityLevel: _isEntityLevel = false }: { isEntityLevel?: boolean } = {},
): ExpectedSubscriptionItemUpdate[] => {
const productId = product.product.id;
const { expectedQuantities } = product;
const consumablePriceId = isEntityLevel
? `stripe_${productId}_consumable_empty`
: `stripe_${productId}_consumable`;
// Entity consumable uses empty price with quantity 0
// Customer consumable is metered (no quantity)
const consumableQuantity = isEntityLevel
? expectedQuantities.consumableEntity
: expectedQuantities.consumable;
// withEntity is hardcoded to false, so always use regular consumable price (metered, no quantity)
const consumablePriceId = `stripe_${productId}_consumable`;
const consumableQuantity = expectedQuantities.consumable;
const items: ExpectedSubscriptionItemUpdate[] = [
{
@@ -370,17 +360,17 @@ export const expectSubscriptionItemsUpdate = (
* Creates stripe subscription items from product's expected prices.
* Useful for setting up existing subscription state.
*
* Note: For customer-level (non-entity) products, metered consumable prices
* are excluded because they don't have a meaningful quantity to compare.
* Entity-level products use the empty price with quantity 0.
* Note: As of current implementation, withEntity is hardcoded to false in customerProductToStripeItemSpecs,
* so consumable prices always use the regular (non-empty) price ID. Metered consumable prices
* are excluded by default because they don't have a meaningful quantity to compare.
*
* @param includeMetered - If true, includes metered consumable even for customer-level.
* Use this when testing scenarios that need the full item set.
* @param includeMetered - If true, includes metered consumable. Use this when testing scenarios
* that need the full item set.
*/
export const createStripeItemsFromProduct = (
product: ReturnType<typeof createProductWithAllPriceTypes>,
{
isEntityLevel = false,
isEntityLevel: _isEntityLevel = false,
itemIdPrefix = "si",
includeMetered = false,
}: {
@@ -410,15 +400,9 @@ export const createStripeItemsFromProduct = (
},
];
// For entity-level, always include empty price with quantity 0
// For customer-level, only include metered if explicitly requested
if (isEntityLevel) {
items.push({
id: `${itemIdPrefix}_${productId}_consumable`,
priceId: `stripe_${productId}_consumable_empty`,
quantity: expectedQuantities.consumableEntity ?? 0,
});
} else if (includeMetered) {
// Only include metered if explicitly requested (withEntity is hardcoded to false,
// so always use regular consumable price)
if (includeMetered) {
items.push({
id: `${itemIdPrefix}_${productId}_consumable`,
priceId: `stripe_${productId}_consumable`,

View File

@@ -105,12 +105,13 @@ describe(
);
expect(prepaidItem?.quantity).toBe(200);
// Consumable (entities use empty price): 0 + 0 = 0
// Consumable (metered, withEntity hardcoded to false): no quantity
const consumableItem = result.find((item) =>
item.price?.includes("consumable"),
);
expect(consumableItem?.price).toBe("stripe_pro_consumable_empty");
expect(consumableItem?.quantity).toBe(0);
expect(consumableItem?.price).toBe("stripe_pro_consumable");
// Metered prices should NOT have quantity
expect("quantity" in (consumableItem ?? {})).toBe(false);
// Allocated: 5 + 5 = 10
const allocatedItem = result.find((item) =>
@@ -293,8 +294,8 @@ describe(
finalCustomerProducts: [entity1ProProduct, entity2ProProduct],
});
// Should update quantities (double them)
expect(result).toHaveLength(3); // fixed, prepaid, allocated (consumable stays 0)
// Should update quantities (double them) + add metered consumable
expect(result).toHaveLength(4); // fixed, prepaid, allocated, consumable (metered)
const fixedItem = result.find((item) => item.id?.includes("fixed"));
expect(fixedItem?.quantity).toBe(2);
@@ -306,6 +307,12 @@ describe(
item.id?.includes("allocated"),
);
expect(allocatedItem?.quantity).toBe(10);
// Metered consumable needs to be added
const consumableItem = result.find((item) =>
item.price?.includes("consumable"),
);
expect(consumableItem).toBeDefined();
});
test("Remove one entity from two-entity subscription", () => {
@@ -334,7 +341,7 @@ describe(
subscriptionIds: ["sub_123"],
});
// Current subscription has 2 entities worth of quantities
// Current subscription has 2 entities worth of quantities (no consumable since withEntity is false)
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: [
@@ -348,11 +355,6 @@ describe(
priceId: "stripe_pro_prepaid",
quantity: 200,
},
{
id: "si_pro_consumable",
priceId: "stripe_pro_consumable_empty",
quantity: 0,
},
{
id: "si_pro_allocated",
priceId: "stripe_pro_allocated",
@@ -373,8 +375,8 @@ describe(
finalCustomerProducts: [entity1ProProduct],
});
// Should update quantities (halve them)
expect(result).toHaveLength(3); // fixed, prepaid, allocated
// Should update quantities (halve them) + add metered consumable
expect(result).toHaveLength(4); // fixed, prepaid, allocated, consumable
const fixedItem = result.find((item) => item.id?.includes("fixed"));
expect(fixedItem?.quantity).toBe(1);
@@ -386,6 +388,12 @@ describe(
item.id?.includes("allocated"),
);
expect(allocatedItem?.quantity).toBe(5);
// Metered consumable needs to be added
const consumableItem = result.find((item) =>
item.price?.includes("consumable"),
);
expect(consumableItem).toBeDefined();
});
});
@@ -524,13 +532,12 @@ describe(
finalCustomerProducts: [customerProProduct, entityProProduct],
});
// Should have 5 items:
// Should have 4 items (withEntity hardcoded to false, so both use metered):
// - fixed (1+1=2)
// - prepaid (100+50=150)
// - allocated (5+3=8)
// - consumable metered (customer)
// - consumable empty (entity)
expect(result).toHaveLength(5);
// - consumable metered (both customer and entity use regular price)
expect(result).toHaveLength(4);
// Fixed: 1 + 1 = 2
const fixedItem = result.find((item) => item.price?.includes("fixed"));
@@ -548,16 +555,11 @@ describe(
);
expect(allocatedItem?.quantity).toBe(8);
// Both consumable prices should be present
// Only one consumable price (metered, withEntity is false)
const consumableMetered = result.find(
(item) => item.price === "stripe_pro_consumable",
);
const consumableEmpty = result.find(
(item) => item.price === "stripe_pro_consumable_empty",
);
expect(consumableMetered).toBeDefined();
expect(consumableEmpty).toBeDefined();
expect(consumableEmpty?.quantity).toBe(0);
// Metered should not have quantity
expect("quantity" in (consumableMetered ?? {})).toBe(false);
});
@@ -588,7 +590,7 @@ describe(
subscriptionIds: ["sub_123"],
});
// Current subscription has both customer and entity
// Current subscription has customer and entity (both use regular metered price - withEntity is false)
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: [
@@ -598,16 +600,6 @@ describe(
priceId: "stripe_pro_prepaid",
quantity: 200,
},
{
id: "si_pro_consumable",
priceId: "stripe_pro_consumable",
quantity: 0,
},
{
id: "si_pro_consumable_empty",
priceId: "stripe_pro_consumable_empty",
quantity: 0,
},
{
id: "si_pro_allocated",
priceId: "stripe_pro_allocated",
@@ -628,7 +620,7 @@ describe(
finalCustomerProducts: [entityProProduct],
});
// Should update quantities and remove metered consumable
// Should update quantities + add metered consumable (wasn't in subscription)
// Fixed: 2 -> 1
const fixedItem = result.find((item) => item.id === "si_pro_fixed");
expect(fixedItem?.quantity).toBe(1);
@@ -643,11 +635,11 @@ describe(
);
expect(allocatedItem?.quantity).toBe(5);
// Metered consumable should be deleted
const deletedMetered = result.find(
(item) => item.id === "si_pro_consumable" && item.deleted,
// Metered consumable should be added (wasn't in subscription)
const consumableItem = result.find(
(item) => item.price === "stripe_pro_consumable",
);
expect(deletedMetered).toBeDefined();
expect(consumableItem).toBeDefined();
});
});

View File

@@ -483,7 +483,7 @@ describe(
expect(result[0].price).toBe("stripe_premium_consumable");
});
test("Entity product, same quantities returns empty array", () => {
test("Entity product, same quantities returns only metered addition", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
@@ -508,7 +508,8 @@ describe(
entityId: "entity_1",
});
// Entity-level includes the empty price with quantity 0
// Entity-level subscription has 3 items (no metered consumable)
// Note: withEntity is hardcoded to false, so entity products use regular metered prices
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(premium, { isEntityLevel: true }),
@@ -526,8 +527,9 @@ describe(
finalCustomerProducts: [entityCustomerProduct],
});
// No changes needed - all quantities match
expect(result).toHaveLength(0);
// Only the metered consumable needs to be added (wasn't in existing subscription)
expect(result).toHaveLength(1);
expect(result[0].price).toBe("stripe_premium_consumable");
});
});
@@ -568,14 +570,14 @@ describe(
]);
});
test("Remove entity product marks all items including consumable as deleted", () => {
test("Remove entity product marks all items as deleted", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium_entity",
});
// Entity-level subscription has 4 items (including empty consumable)
// Entity-level subscription has 3 items (no metered consumable - withEntity is hardcoded to false)
const stripeSubscription = stripeSubscriptions.create({
id: "sub_123",
items: createStripeItemsFromProduct(premium, { isEntityLevel: true }),
@@ -593,12 +595,11 @@ describe(
finalCustomerProducts: [],
});
// Should delete all 4 items
expect(result).toHaveLength(4);
// Should delete all 3 items (fixed, prepaid, allocated)
expect(result).toHaveLength(3);
expectSubscriptionItemsUpdate(result, [
{ id: "si_premium_fixed", deleted: true },
{ id: "si_premium_prepaid", deleted: true },
{ id: "si_premium_consumable", deleted: true },
{ id: "si_premium_allocated", deleted: true },
]);
});
@@ -753,7 +754,7 @@ describe(
expect(result).toHaveLength(0);
});
test("Entity-level consumable uses stripe_empty_price_id with quantity 0", () => {
test("Entity-level consumable uses regular metered price (withEntity hardcoded to false)", () => {
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
@@ -787,13 +788,14 @@ describe(
finalCustomerProducts: [entityCustomerProduct],
});
// Find the consumable item
// Find the consumable item - now uses regular metered price (withEntity is hardcoded to false)
const consumableItem = result.find((item) =>
item.price?.includes("consumable"),
);
expect(consumableItem).toBeDefined();
expect(consumableItem?.price).toBe("stripe_premium_consumable_empty");
expect(consumableItem?.quantity).toBe(0);
expect(consumableItem?.price).toBe("stripe_premium_consumable");
// Metered prices should NOT have quantity
expect("quantity" in (consumableItem ?? {})).toBe(false);
});
test("Customer-level consumable (metered) has no quantity", () => {

View File

@@ -8,14 +8,14 @@
*/
import { describe, expect, test } from "bun:test";
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
import type { UpdateSubscriptionV1Params } from "@autumn/shared";
import chalk from "chalk";
import {
computeUpdateSubscriptionIntent,
UpdateSubscriptionIntent,
} from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionIntent";
const baseParams: UpdateSubscriptionV0Params = {
const baseParams: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
product_id: "prod_test",
};
@@ -23,7 +23,7 @@ const baseParams: UpdateSubscriptionV0Params = {
describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
describe(chalk.cyan("Version parameter priority"), () => {
test("returns UpdatePlan when version is specified", () => {
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
...baseParams,
version: 2,
};
@@ -34,7 +34,7 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
});
test("returns UpdatePlan when version is specified even with options", () => {
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
...baseParams,
version: 3,
options: [{ feature_id: "seats", quantity: 10 }],
@@ -46,7 +46,7 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
});
test("returns UpdatePlan when version is 0", () => {
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
...baseParams,
version: 0,
};
@@ -58,8 +58,8 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
});
describe(chalk.cyan("UpdateQuantity intent"), () => {
test("returns UpdateQuantity when options provided without items", () => {
const params: UpdateSubscriptionV0Params = {
test("returns UpdateQuantity when options provided without customize", () => {
const params: UpdateSubscriptionV1Params = {
...baseParams,
options: [{ feature_id: "seats", quantity: 5 }],
};
@@ -70,7 +70,7 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
});
test("returns UpdateQuantity with multiple options", () => {
const params: UpdateSubscriptionV0Params = {
const params: UpdateSubscriptionV1Params = {
...baseParams,
options: [
{ feature_id: "seats", quantity: 5 },
@@ -85,10 +85,12 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
});
describe(chalk.cyan("UpdatePlan intent (default)"), () => {
test("returns UpdatePlan when items provided", () => {
const params: UpdateSubscriptionV0Params = {
test("returns UpdatePlan when customize provided", () => {
const params: UpdateSubscriptionV1Params = {
...baseParams,
items: [{ feature_id: "seats", included_usage: 10 }],
customize: {
items: [{ feature_id: "seats", included: 10 }],
},
};
const result = computeUpdateSubscriptionIntent(params);
@@ -96,11 +98,13 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
expect(result).toBe(UpdateSubscriptionIntent.UpdatePlan);
});
test("returns UpdatePlan when both options and items provided", () => {
const params: UpdateSubscriptionV0Params = {
test("returns UpdatePlan when both options and customize provided", () => {
const params: UpdateSubscriptionV1Params = {
...baseParams,
options: [{ feature_id: "seats", quantity: 5 }],
items: [{ feature_id: "seats", included_usage: 10 }],
customize: {
items: [{ feature_id: "seats", included: 10 }],
},
};
const result = computeUpdateSubscriptionIntent(params);

View File

@@ -54,6 +54,7 @@ const createBilling = ({
currentEpochMs = Date.now(),
billingCycleAnchorMs = "now",
resetCycleAnchorMs = "now",
billingVersion = BillingVersion.V1,
}: {
customerProducts?: FullCusProduct[];
fullProducts?: FullProduct[];
@@ -62,6 +63,7 @@ const createBilling = ({
currentEpochMs?: number;
billingCycleAnchorMs?: number | "now";
resetCycleAnchorMs?: number | "now";
billingVersion?: BillingVersion;
}): BillingContext => ({
fullCustomer: customers.create({ customerProducts }),
stripeCustomer: stripeCustomers.create(),
@@ -75,7 +77,7 @@ const createBilling = ({
customPrices: [],
customEnts: [],
isCustom: false,
billingVersion: BillingVersion.V2,
billingVersion,
});
// ═══════════════════════════════════════════════════════════════════

View File

@@ -3,6 +3,7 @@ import {
type EntityBalance,
FeatureType,
type FullCustomerEntitlement,
type RolloverConfig,
} from "@autumn/shared";
import { entitlements } from "./entitlements";
@@ -26,6 +27,7 @@ const create = ({
nextResetAt = null,
entities = null,
entityFeatureId = null,
rollover = null,
}: {
id?: string;
entitlementId?: string;
@@ -43,6 +45,7 @@ const create = ({
nextResetAt?: number | null;
entities?: Record<string, EntityBalance> | null;
entityFeatureId?: string | null;
rollover?: RolloverConfig | null;
}): FullCustomerEntitlement => {
const entId = entitlementId ?? `ent_${featureId}`;
return {
@@ -72,6 +75,7 @@ const create = ({
interval,
intervalCount,
entityFeatureId,
rollover,
}),
replaceables: [],
rollovers: [],

View File

@@ -1,4 +1,9 @@
import { AllowanceType, type EntInterval, FeatureType } from "@autumn/shared";
import {
AllowanceType,
type EntInterval,
FeatureType,
type RolloverConfig,
} from "@autumn/shared";
import { features } from "./features";
/**
@@ -15,6 +20,7 @@ const create = ({
interval = null,
intervalCount = 1,
entityFeatureId = null,
rollover = null,
}: {
id?: string;
featureId: string;
@@ -26,6 +32,7 @@ const create = ({
interval?: EntInterval | null;
intervalCount?: number;
entityFeatureId?: string | null;
rollover?: RolloverConfig | null;
}) => ({
id: id ?? `ent_${featureId}_${crypto.randomUUID().slice(0, 8)}`,
created_at: Date.now(),
@@ -40,7 +47,7 @@ const create = ({
entity_feature_id: entityFeatureId,
feature_id: featureId,
usage_limit: null,
rollover: null,
rollover,
feature: features.create({
id: featureId,
internalId: internalFeatureId,

View File

@@ -43,6 +43,7 @@ const createPrepaid = ({
usage_tiers: [{ to: Infinite, amount: 10 }],
interval: BillingInterval.Month,
stripe_price_id: stripePriceId ?? `stripe_price_${id}`,
stripe_prepaid_price_v2_id: stripePriceId ?? `stripe_price_${id}`,
},
}) as Price;

View File

@@ -1,4 +1,9 @@
import { AppEnv, type FullProduct, type Price } from "@autumn/shared";
import {
AppEnv,
type EntitlementWithFeature,
type FullProduct,
type Price,
} from "@autumn/shared";
/**
* Create a product fixture
@@ -33,12 +38,14 @@ const createFull = ({
id = "prod_test",
name = "Test Product",
prices = [],
entitlements = [],
stripeProductId,
isAddOn = false,
}: {
id?: string;
name?: string;
prices?: Price[];
entitlements?: EntitlementWithFeature[];
stripeProductId?: string;
isAddOn?: boolean;
}): FullProduct =>
@@ -58,7 +65,7 @@ const createFull = ({
base_variant_id: null,
archived: false,
prices,
entitlements: [],
entitlements,
free_trial: null,
}) as FullProduct;

View File

@@ -0,0 +1,133 @@
import {
BillingInterval,
BillingMethod,
OnDecrease,
OnIncrease,
ResetInterval,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
const monthlyPrice = ({
amount = 20,
intervalCount,
}: {
amount?: number;
intervalCount?: number;
} = {}) => ({
amount,
interval: BillingInterval.Month,
interval_count: intervalCount,
});
const annualPrice = ({
amount = 200,
intervalCount,
}: {
amount?: number;
intervalCount?: number;
} = {}) => ({
amount,
interval: BillingInterval.Year,
interval_count: intervalCount,
});
const monthlyMessages = ({ included = 100 }: { included?: number } = {}) => ({
feature_id: TestFeature.Messages,
included,
reset: {
interval: ResetInterval.Month,
},
});
const monthlyWords = ({ included = 100 }: { included?: number } = {}) => ({
feature_id: TestFeature.Words,
included,
reset: {
interval: ResetInterval.Month,
},
});
const dashboard = () => ({
feature_id: TestFeature.Dashboard,
});
const prepaidMessages = ({
amount = 10,
billingUnits = 100,
included = 0,
}: {
amount?: number;
billingUnits?: number;
included?: number;
} = {}) => ({
feature_id: TestFeature.Messages,
included,
price: {
amount,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: billingUnits,
},
});
const prepaidWords = ({
amount = 10,
billingUnits = 100,
included = 0,
}: {
amount?: number;
billingUnits?: number;
included?: number;
} = {}) => ({
feature_id: TestFeature.Words,
included,
price: {
amount,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: billingUnits,
},
});
const consumableMessages = ({ amount = 1 }: { amount?: number } = {}) => ({
feature_id: TestFeature.Messages,
price: {
amount,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
});
const allocatedUsers = ({
amount = 10,
included = 0,
}: {
amount?: number;
included?: number;
} = {}) => ({
feature_id: TestFeature.Users,
included,
price: {
amount,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
proration: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.Prorate,
},
});
export const itemsV2 = {
monthlyPrice,
annualPrice,
monthlyMessages,
monthlyWords,
dashboard,
prepaidMessages,
prepaidWords,
consumableMessages,
allocatedUsers,
} as const;

View File

@@ -4,6 +4,7 @@ import {
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import type { z } from "zod/v4";
import type { SharedContext } from "../../../../types/sharedContext.js";
import { CheckQuerySchema } from "../checkParams.js";
import { CheckExpand } from "../enums/CheckExpand.js";
@@ -42,8 +43,10 @@ export const V1_2_CheckQueryChange = defineVersionChange({
// Request: V1.2 → V2.0 (add expand option)
transformRequest: ({
ctx: _ctx,
input,
}: {
ctx: SharedContext;
input: z.infer<typeof CheckQuerySchema>;
}) => {
const existingExpand = input.expand || [];

View File

@@ -4,6 +4,7 @@ import {
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import type { z } from "zod/v4";
import type { SharedContext } from "../../../../types/sharedContext.js";
import { TrackParamsSchema } from "../trackParams.js";
/**
@@ -31,8 +32,10 @@ export const V1_2_TrackParamsChange = defineVersionChange({
// Request: V1.2 → V2.0 (extract properties.value to value if not already set)
transformRequest: ({
ctx: _ctx,
input,
}: {
ctx: SharedContext;
input: z.infer<typeof TrackParamsSchema>;
}): z.infer<typeof TrackParamsSchema> => {
// Keep original value if provided, otherwise extract from properties.value

View File

@@ -1,13 +1,11 @@
import { RedirectModeSchema } from "@api/billing/common/redirectMode.js";
import { z } from "zod/v4";
import { PlanTimingSchema } from "../../../models/billingModels/context/attachBillingContext.js";
import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels.js";
import { BillingBehaviorSchema } from "../common/billingBehavior.js";
import { BillingParamsBaseSchema } from "../common/billingParamsBase.js";
import { BillingParamsBaseV0Schema } from "../common/billingParamsBase/billingParamsBaseV0.js";
export const RedirectModeSchema = z.enum(["always", "if_required", "never"]);
export type RedirectMode = z.infer<typeof RedirectModeSchema>;
export const ExtAttachParamsV0Schema = BillingParamsBaseSchema.extend({
export const ExtAttachParamsV0Schema = BillingParamsBaseV0Schema.extend({
// Product identification
product_id: z.string(),

View File

@@ -0,0 +1,48 @@
import { BillingParamsBaseV1Schema } from "@api/billing/common/billingParamsBase/billingParamsBaseV1.js";
import { z } from "zod/v4";
import { PlanTimingSchema } from "../../../models/billingModels/context/attachBillingContext.js";
import { BillingBehaviorSchema } from "../common/billingBehavior.js";
import { RedirectModeSchema } from "../common/redirectMode.js";
export const AttachParamsV1Schema = BillingParamsBaseV1Schema.extend({
// Product identification
product_id: z.string(),
// Invoice mode
invoice: z.boolean().optional(),
enable_product_immediately: z.boolean().optional(),
finalize_invoice: z.boolean().optional(),
// invoice_mode: z
// .object({
// enabled: z.boolean(),
// enable_product_immediately: z.boolean(),
// finalize_invoice: z.boolean(),
// })
// .optional(),
// Checkout behavior
redirect_mode: RedirectModeSchema.default("always"),
success_url: z.string().optional(),
new_billing_subscription: z.boolean().optional(),
plan_schedule: PlanTimingSchema.optional(),
billing_behavior: BillingBehaviorSchema.optional(),
adjustable_quantity: z.boolean().optional(),
});
// export const AttachParamsV1Schema = ExtAttachParamsV1Schema.extend({
// // Custom product configuration
// items: z.array(ProductItemSchema).optional(),
// }).refine(
// (data) => {
// if (data.items && data.items.length === 0) {
// return false;
// }
// return true;
// },
// {
// message: "Must provide at least one item when using custom plan",
// },
// );
export type AttachParamsV1 = z.infer<typeof AttachParamsV1Schema>;
export type AttachParamsV1Input = z.input<typeof AttachParamsV1Schema>;

View File

@@ -0,0 +1,50 @@
import { freeTrialParamsV0ToV1 } from "@api/common/freeTrial/mappers/freeTrialParamsV0ToV1.js";
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { productItemsToCustomizePlanV1 } from "@utils/productV2Utils/productItemUtils/convertProductItem/productItemsToCustomizePlanV1.js";
import type { z } from "zod/v4";
import type { SharedContext } from "../../../../types/sharedContext.js";
import { AttachParamsV0Schema } from "../attachParamsV0.js";
import { AttachParamsV1Schema } from "../attachParamsV1.js";
export const V1_2_AttachParamsChange = defineVersionChange({
name: "V1.2 Attach Params Change",
newVersion: ApiVersion.V2_0,
oldVersion: ApiVersion.V1_Beta,
description: [
"Maps free_trial {length,duration} to {duration_length,duration_type}",
"Maps top-level items to customize.items",
],
affectedResources: [AffectedResource.Attach],
newSchema: AttachParamsV1Schema,
oldSchema: AttachParamsV0Schema,
affectsRequest: true,
affectsResponse: false,
transformRequest: ({
ctx,
input,
}: {
ctx: SharedContext;
input: z.infer<typeof AttachParamsV0Schema>;
}): z.infer<typeof AttachParamsV1Schema> => {
const customizeV1 = input.items
? productItemsToCustomizePlanV1({
ctx,
items: input.items,
})
: undefined;
const freeTrialV1 = freeTrialParamsV0ToV1({
freeTrialParamsV0: input.free_trial,
});
return {
...input,
free_trial: freeTrialV1,
customize: customizeV1,
};
},
});

View File

@@ -2,10 +2,10 @@ import { FeatureOptionsParamsV0Schema } from "@api/billing/common/featureOptions
import { FreeTrialParamsV0Schema } from "@api/common/freeTrial/freeTrialParamsV0.js";
import { ProductItemSchema } from "@models/productV2Models/productItemModels/productItemModels.js";
import { z } from "zod/v4";
import { CustomerDataSchema } from "../../common/customerData.js";
import { EntityDataSchema } from "../../common/entityData.js";
import { CustomerDataSchema } from "../../../common/customerData.js";
import { EntityDataSchema } from "../../../common/entityData.js";
export const BillingParamsBaseSchema = z.object({
export const BillingParamsBaseV0Schema = z.object({
customer_id: z.string(),
entity_id: z.string().nullish(),
customer_data: CustomerDataSchema.optional(),
@@ -18,4 +18,4 @@ export const BillingParamsBaseSchema = z.object({
items: z.array(ProductItemSchema).optional(),
});
export type BillingParamsBase = z.infer<typeof BillingParamsBaseSchema>;
export type BillingParamsBaseV0 = z.infer<typeof BillingParamsBaseV0Schema>;

View File

@@ -0,0 +1,22 @@
import { FeatureOptionsParamsV0Schema } from "@api/billing/common/featureOptions/featureOptionsParamsV0.js";
import { FreeTrialParamsV1Schema } from "@api/common/freeTrial/freeTrialParamsV1.js";
import { z } from "zod/v4";
import { CustomerDataSchema } from "../../../common/customerData.js";
import { EntityDataSchema } from "../../../common/entityData.js";
import { CustomizePlanV1Schema } from "../customizePlan/customizePlanV1.js";
export const BillingParamsBaseV1Schema = z.object({
customer_id: z.string(),
entity_id: z.string().nullish(),
customer_data: CustomerDataSchema.optional(),
entity_data: EntityDataSchema.optional(),
// Used for both update and attach
options: z.array(FeatureOptionsParamsV0Schema).nullish(),
version: z.number().optional(),
free_trial: FreeTrialParamsV1Schema.nullable().optional(),
customize: CustomizePlanV1Schema.optional(),
});
export type BillingParamsBaseV1 = z.infer<typeof BillingParamsBaseV1Schema>;

View File

@@ -28,7 +28,6 @@ export const BillingResponseSchema = z.object({
.optional(),
payment_url: z.string().nullable(),
required_action: BillingResponseRequiredActionSchema.optional(),
});

View File

@@ -0,0 +1,6 @@
import { ProductItemSchema } from "@models/productV2Models/productItemModels/productItemModels";
import { z } from "zod/v4";
export const CustomizePlanV0Schema = z.array(ProductItemSchema);
export type CustomizePlanV0 = z.infer<typeof CustomizePlanV0Schema>;

View File

@@ -0,0 +1,14 @@
import { BasePriceParamsSchema } from "@api/products/components/basePrice/basePrice";
import { CreatePlanItemParamsV1Schema } from "@api/products/items/crud/createPlanItemParamsV1";
import { z } from "zod/v4";
export const CustomizePlanV1Schema = z
.object({
price: BasePriceParamsSchema.nullable().optional(), // null to remove base price
items: z.array(CreatePlanItemParamsV1Schema).optional(),
})
.refine((data) => data.items !== undefined || data.price !== undefined, {
message: "When using customize, either items or price must be provided",
});
export type CustomizePlanV1 = z.infer<typeof CustomizePlanV1Schema>;

View File

@@ -0,0 +1,62 @@
import { basePriceToProductItem } from "@api/products/components/basePrice/basePriceToProductItem";
import { planV1ToProductItems } from "@api/products/mappers/planV1ToProductItems";
import type { FullProduct } from "@models/productModels/productModels";
import { isPriceItem, mapToProductItems } from "@utils/index";
import type { SharedContext } from "../../../../../types/sharedContext";
import type { CustomizePlanV0 } from "../customizePlanV0";
import type { CustomizePlanV1 } from "../customizePlanV1";
export const customizePlanV1ToV0 = ({
ctx,
customizePlanV1,
fullProduct,
}: {
ctx: SharedContext;
customizePlanV1: CustomizePlanV1;
fullProduct: FullProduct;
}): CustomizePlanV0 => {
const currentProductItems = mapToProductItems({
prices: fullProduct.prices,
entitlements: fullProduct.entitlements,
features: ctx.features,
});
if (
customizePlanV1.price !== undefined &&
customizePlanV1.items !== undefined
) {
// 1. If price AND items provided, return full items array
return planV1ToProductItems({
ctx,
plan: { price: customizePlanV1.price, items: customizePlanV1.items },
});
} else if (
customizePlanV1.price !== undefined &&
customizePlanV1.items === undefined
) {
// 2. If price provided, but no items, then customize base price and carry over feature items
const featureItems = currentProductItems.filter(
(item) => !isPriceItem(item),
);
const basePriceItem = customizePlanV1.price
? basePriceToProductItem({
ctx,
basePrice: customizePlanV1.price,
})
: undefined;
return basePriceItem ? [basePriceItem, ...featureItems] : featureItems;
} else {
// 3. If no price provided, then carry over base price
const basePriceItem = currentProductItems.filter((item) =>
isPriceItem(item),
);
const featureItems = planV1ToProductItems({
ctx,
plan: { price: null, items: customizePlanV1.items ?? [] },
});
return [...basePriceItem, ...featureItems];
}
};

View File

@@ -0,0 +1,4 @@
import { z } from "zod/v4";
export const RedirectModeSchema = z.enum(["always", "if_required", "never"]);
export type RedirectMode = z.infer<typeof RedirectModeSchema>;

View File

@@ -4,16 +4,24 @@ export * from "./attach/prevVersions/attachBodyV0.js";
export * from "./attach/prevVersions/attachResponseV1.js";
// Attach V2
export * from "./attachV2/attachParamsV0.js";
export * from "./attachV2/attachParamsV1.js";
// Checkout
export * from "./checkout/prevVersions/checkoutParamsV0.js";
export * from "./checkout/prevVersions/checkoutResponseV0.js";
// Common
export * from "./common/billingParamsBase.js";
export * from "./common/billingParamsBase/billingParamsBaseV0.js";
export * from "./common/billingParamsBase/billingParamsBaseV1.js";
export * from "./common/billingPreviewResponse.js";
export * from "./common/billingResponse.js";
export * from "./common/cancelAction.js";
export * from "./common/customizePlan/customizePlanV0.js";
export * from "./common/customizePlan/customizePlanV1.js";
export * from "./common/customizePlan/mappers/customizePlanV1ToV0.js";
export * from "./common/redirectMode.js";
export * from "./common/refundBehavior.js";
export * from "./updateSubscription/previewUpdateSubscriptionResponse.js";
// Update Subscription
export * from "./updateSubscription/updateSubscriptionV0Params.js";
export * from "./updateSubscription/updateSubscriptionV1Params.js";

View File

@@ -0,0 +1,50 @@
import { freeTrialParamsV0ToV1 } from "@api/common/freeTrial/mappers/freeTrialParamsV0ToV1.js";
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { productItemsToCustomizePlanV1 } from "@utils/productV2Utils/productItemUtils/convertProductItem/productItemsToCustomizePlanV1.js";
import type { z } from "zod/v4";
import type { SharedContext } from "../../../../types/sharedContext.js";
import { UpdateSubscriptionV0ParamsSchema } from "../updateSubscriptionV0Params.js";
import { UpdateSubscriptionV1ParamsSchema } from "../updateSubscriptionV1Params.js";
export const V1_2_UpdateSubscriptionParamsChange = defineVersionChange({
name: "V1.2 Update Subscription Params Change",
newVersion: ApiVersion.V2_0,
oldVersion: ApiVersion.V1_Beta,
description: [
"Maps free_trial {length,duration} to {duration_length,duration_type}",
"Maps top-level items to customize.items",
],
affectedResources: [AffectedResource.ApiSubscriptionUpdate],
newSchema: UpdateSubscriptionV1ParamsSchema,
oldSchema: UpdateSubscriptionV0ParamsSchema,
affectsRequest: true,
affectsResponse: false,
transformRequest: ({
ctx,
input,
}: {
ctx: SharedContext;
input: z.infer<typeof UpdateSubscriptionV0ParamsSchema>;
}): z.infer<typeof UpdateSubscriptionV1ParamsSchema> => {
const customizeV1 = input.items
? productItemsToCustomizePlanV1({
ctx,
items: input.items,
})
: undefined;
const freeTrialV1 = freeTrialParamsV0ToV1({
freeTrialParamsV0: input.free_trial,
});
return {
...input,
free_trial: freeTrialV1,
customize: customizeV1,
};
},
});

View File

@@ -2,11 +2,11 @@ import { RefundBehaviorSchema } from "@api/billing/common/refundBehavior";
import { nullish } from "@utils/utils";
import { z } from "zod/v4";
import { BillingBehaviorSchema } from "../common/billingBehavior";
import { BillingParamsBaseSchema } from "../common/billingParamsBase";
import { BillingParamsBaseV0Schema } from "../common/billingParamsBase/billingParamsBaseV0";
import { CancelActionSchema } from "../common/cancelAction";
export const ExtUpdateSubscriptionV0ParamsSchema =
BillingParamsBaseSchema.extend({
BillingParamsBaseV0Schema.extend({
// Product identification (optional for update subscription - can target by customer_product_id)
product_id: z.string().nullish(),

View File

@@ -0,0 +1,99 @@
import { nullish } from "@utils/utils";
import { z } from "zod/v4";
import { BillingBehaviorSchema } from "../common/billingBehavior";
import { BillingParamsBaseV1Schema } from "../common/billingParamsBase/billingParamsBaseV1";
import { CancelActionSchema } from "../common/cancelAction";
export const UpdateSubscriptionV1ParamsSchema =
BillingParamsBaseV1Schema.extend({
product_id: z.string().nullish(),
invoice: z.boolean().optional(),
enable_product_immediately: z.boolean().optional(),
finalize_invoice: z.boolean().optional(),
cancel_action: CancelActionSchema.optional(),
billing_behavior: BillingBehaviorSchema.optional(),
customer_product_id: z.string().optional().meta({
internal: true,
}),
})
.check((ctx) => {
if (ctx.value.options && ctx.value.options.length > 0) {
const invalidFeatures = ctx.value.options
.filter((opt) => nullish(opt.quantity) || opt.quantity < 0)
.map((opt) => opt.feature_id);
if (invalidFeatures.length > 0) {
ctx.issues.push({
code: "custom",
message: `Options quantity must be >= 0 for features: ${invalidFeatures.join(", ")}`,
input: ctx.value,
});
}
}
})
.refine(
(data) => {
if (data.cancel_action !== "cancel_immediately") return true;
const forbiddenFields = [
"options",
"version",
"free_trial",
"customize",
] as const;
return !forbiddenFields.some((field) => data[field] !== undefined);
},
{
message:
"Cannot pass options, items, version, or free_trial when cancel_action is 'cancel_immediately'. Immediate cancellation only processes a prorated refund.",
},
)
.refine(
(data) => {
if (data.cancel_action !== "cancel_end_of_cycle") return true;
// Cannot pass free_trial when cancel_action is 'cancel_end_of_cycle'
return data.free_trial === undefined;
},
{
message:
"Cannot pass free_trial when cancel_action is 'cancel_end_of_cycle'.",
},
);
export type UpdateSubscriptionV1Params = z.infer<
typeof UpdateSubscriptionV1ParamsSchema
>;
export type UpdateSubscriptionV1ParamsInput = z.input<
typeof UpdateSubscriptionV1ParamsSchema
>;
// Schedules (epoch milliseconds)
// plan_custom_start_date: z.number().optional(),
// billing_cycle_anchor: z.number().optional(),
// keep_existing_plan: true, //disable_plan_switch
// prorate_billing: true,
// invoice_only: true,
// carry_over_balance: true,
// reset_usage: true,
// billing_custom_start_date: "2025-11-04",
// billing_custom_end_date: "2025-12-04",
// billing_cycle_anchor: "2025-11-04",
// billing_due_date: "2025-11-04",
// new_billing_subscription: true, //fka combine_subscriptions, separate_billing_subscriptions
// require_payment_method: true, //fka force_checkout
// reset_balances: true
// plan_schedule: "immediate", // or "next_cycle", "custom_date"
// plan_custom_start_date: "2025-11-04",
// plan_custom_end_date: "2025-12-04",
// billing_schedule: "immediate", // or "next_cycle", "custom_date"

View File

@@ -0,0 +1,20 @@
import type { FreeTrialParamsV0 } from "@api/common/freeTrial/freeTrialParamsV0";
import type { FreeTrialParamsV1 } from "@api/common/freeTrial/freeTrialParamsV1";
export const freeTrialParamsV0ToV1 = ({
freeTrialParamsV0,
}: {
freeTrialParamsV0: FreeTrialParamsV0 | null | undefined;
}): FreeTrialParamsV1 | null | undefined => {
// If it's undefined, means no action
if (freeTrialParamsV0 === undefined) return undefined;
// If it's null, means remove the trial
if (freeTrialParamsV0 === null) return null;
return {
duration_length: freeTrialParamsV0.length,
duration_type: freeTrialParamsV0.duration,
card_required: freeTrialParamsV0.card_required,
};
};

View File

@@ -5,6 +5,7 @@ import {
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { CusExpand } from "@models/cusModels/cusExpand.js";
import type { z } from "zod/v4";
import type { SharedContext } from "../../../types/sharedContext.js";
import { GetCustomerQuerySchema } from "../customerOpModels.js";
/**
@@ -43,8 +44,10 @@ export const V1_2_CustomerQueryChange = defineVersionChange({
// Request: V1.2 → V2.0 (add expand options)
transformRequest: ({
ctx: _ctx,
input,
}: {
ctx: SharedContext;
input: z.infer<typeof GetCustomerQuerySchema>;
}) => {
const existingExpand = input.expand || [];

View File

@@ -5,6 +5,7 @@ import {
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { CusExpand } from "@models/cusModels/cusExpand.js";
import type { z } from "zod/v4";
import type { SharedContext } from "../../../types/sharedContext.js";
import {
type GetEntityQuery,
GetEntityQuerySchema,
@@ -46,8 +47,10 @@ export const V1_2_EntityQueryChange = defineVersionChange({
// Request: V1.2 → V2.0 (add expand options)
transformRequest: ({
ctx: _ctx,
input,
}: {
ctx: SharedContext;
input: z.infer<typeof GetEntityQuerySchema>;
}) => {
const existingExpand = input.expand || [];

View File

@@ -4,6 +4,7 @@ import {
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import type { z } from "zod/v4";
import type { SharedContext } from "../../../types/sharedContext.js";
import { featureV0ToV1Type } from "../../../utils/featureUtils/convertFeatureUtils.js";
import { CreateFeatureV1ParamsSchema } from "../featureV1OpModels.js";
import { CreateFeatureV0ParamsSchema } from "../prevVersions/featureV0OpModels.js";
@@ -46,8 +47,10 @@ export const V1_2_CreateFeatureChange = defineVersionChange({
// Request: V0 → V1 (old format to new)
transformRequest: ({
ctx: _ctx,
input,
}: {
ctx: SharedContext;
input: z.infer<typeof CreateFeatureV0ParamsSchema>;
}): z.infer<typeof CreateFeatureV1ParamsSchema> => {
const { type, consumable } = featureV0ToV1Type({ type: input.type });

View File

@@ -0,0 +1,26 @@
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
import { z } from "zod/v4";
import { DisplaySchema } from "../display";
export const BasePriceSchema = z.object({
amount: z.number(),
interval: z.enum(BillingInterval),
interval_count: z.number().optional(),
display: DisplaySchema.optional(),
});
export const BasePriceParamsSchema = BasePriceSchema.omit({
display: true,
}).extend({
interval_count: z.number().optional(),
entitlement_id: z.string().optional().meta({
internal: true,
}),
price_id: z.string().optional().meta({
internal: true,
}),
});
export type BasePrice = z.infer<typeof BasePriceSchema>;
export type BasePriceParams = z.infer<typeof BasePriceParamsSchema>;

View File

@@ -0,0 +1,53 @@
import type {
BasePrice,
BasePriceParams,
} from "@api/products/components/basePrice/basePrice";
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
import {
type ProductItem,
ProductItemType,
} from "@models/productV2Models/productItemModels/productItemModels";
import { getProductItemDisplay } from "@utils/productDisplayUtils";
import { billingToItemInterval } from "@utils/productV2Utils/productItemUtils/itemIntervalUtils";
import type { SharedContext } from "../../../../types/sharedContext";
export const basePriceToProductItem = ({
ctx,
basePrice,
}: {
ctx: SharedContext;
basePrice: BasePrice | BasePriceParams;
}): ProductItem => {
const basePriceDisplay =
"display" in basePrice ? basePrice.display : undefined;
const entitlementId =
"entitlement_id" in basePrice
? (basePrice.entitlement_id ?? undefined)
: undefined;
const priceId =
"price_id" in basePrice ? (basePrice.price_id ?? undefined) : undefined;
const item = {
type: ProductItemType.Price,
feature_id: null,
feature: null,
interval: billingToItemInterval({
billingInterval: basePrice.interval ?? BillingInterval.Month,
}),
interval_count: basePrice.interval_count ?? 1,
price: basePrice.amount ?? 0,
entitlement_id: entitlementId,
price_id: priceId,
} satisfies ProductItem;
const display = basePriceDisplay
? getProductItemDisplay({ item, features: ctx.features })
: undefined;
return {
...item,
display,
};
};

View File

@@ -1,5 +1,5 @@
import { FreeTrialParamsV1Schema } from "@api/common/freeTrial/freeTrialParamsV1.js";
import { BillingInterval } from "@models/productModels/intervals/billingInterval.js";
import { BasePriceParamsSchema } from "@api/products/components/basePrice/basePrice.js";
import { idRegex } from "@utils/utils.js";
import { z } from "zod/v4";
import { CreatePlanItemParamsV1Schema } from "../items/crud/createPlanItemParamsV1.js";
@@ -14,13 +14,7 @@ export const CreatePlanParamsV1Schema = z.object({
add_on: z.boolean().default(false),
auto_enable: z.boolean().default(false),
price: z
.object({
amount: z.number(),
interval: z.enum(BillingInterval),
interval_count: z.number().optional(),
})
.optional(),
price: BasePriceParamsSchema.optional(),
items: z.array(CreatePlanItemParamsV1Schema).optional(),
free_trial: FreeTrialParamsV1Schema.optional(),

View File

@@ -45,11 +45,19 @@ export const CreatePlanItemParamsV1Schema = z
rollover: z
.object({
max: z.number(),
max: z.number().optional(),
expiry_duration_type: z.enum(RolloverExpiryDurationType),
expiry_duration_length: z.number().optional(),
})
.optional(),
// Internal
entitlement_id: z.string().optional().meta({
internal: true,
}),
price_id: z.string().optional().meta({
internal: true,
}),
})
.check((ctx) => {
const resetInterval = ctx.value.reset?.interval;

View File

@@ -51,7 +51,7 @@ export function planItemParamsV1ToPlanItemV0({
rollover: item.rollover
? {
max: item.rollover.max,
max: item.rollover.max ?? null,
expiry_duration_type: item.rollover.expiry_duration_type,
expiry_duration_length: item.rollover.expiry_duration_length,
}

View File

@@ -119,6 +119,10 @@ export const planItemV0ToProductItem = ({
? ProductItemType.FeaturePrice
: ProductItemType.Feature;
const entitlementId =
"entitlement_id" in planItem ? planItem.entitlement_id : undefined;
const priceId = "price_id" in planItem ? planItem.price_id : undefined;
return ProductItemSchema.parse({
type,
@@ -155,5 +159,8 @@ export const planItemV0ToProductItem = ({
config,
display: "display" in planItem ? planItem.display : undefined,
entitlement_id: entitlementId,
price_id: priceId,
} satisfies ProductItem);
};

View File

@@ -1,12 +1,19 @@
import { billingMethodToUsageModel } from "@api/products/components/mappers/billingMethodTousageModel.js";
import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js";
import type { ApiPlanItemV1 } from "../apiPlanItemV1.js";
import type { CreatePlanItemParamsV1 } from "../crud/createPlanItemParamsV1.js";
/** Transform ApiPlanItemV1 to ApiPlanItemV0 */
export function planItemV1ToV0(item: ApiPlanItemV1): ApiPlanItemV0 {
const { included, price, ...restItem } = item;
export function planItemV1ToV0(
item: ApiPlanItemV1 | CreatePlanItemParamsV1,
): ApiPlanItemV0 {
const { included = 0, price, ...restItem } = item;
const billingUnits = price?.billing_units ?? 1;
return {
...restItem,
unlimited: item.unlimited ?? false,
granted_balance: included,
reset: item.reset
? {
@@ -21,10 +28,21 @@ export function planItemV1ToV0(item: ApiPlanItemV1): ApiPlanItemV0 {
tiers: price.tiers,
interval: price.interval,
interval_count: price.interval_count,
billing_units: price.billing_units,
billing_units: billingUnits,
usage_model: billingMethodToUsageModel(price.billing_method),
max_purchase: price.max_purchase,
max_purchase: price.max_purchase ?? null,
}
: null,
rollover: item.rollover
? {
max: item.rollover.max ?? null,
expiry_duration_type: item.rollover.expiry_duration_type,
expiry_duration_length: item.rollover.expiry_duration_length,
}
: undefined,
entitlement_id: "entitlement_id" in item ? item.entitlement_id : undefined,
price_id: "price_id" in item ? item.price_id : undefined,
};
}

View File

@@ -57,6 +57,13 @@ export const ApiPlanItemV0Schema = z
on_decrease: z.enum(OnDecrease).optional(),
})
.optional(),
entitlement_id: z.string().optional().meta({
internal: true,
}),
price_id: z.string().optional().meta({
internal: true,
}),
})
.check((ctx) => {
const resetInterval = ctx.value.reset?.interval;

View File

@@ -1,50 +0,0 @@
import type { ApiPlanItemV0 } from "@api/models";
import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0";
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
import {
type ProductItem,
ProductItemType,
} from "@models/productV2Models/productItemModels/productItemModels";
import { getProductItemDisplay } from "@utils/productDisplayUtils";
import { billingToItemInterval } from "@utils/productV2Utils/productItemUtils/itemIntervalUtils";
import type { SharedContext } from "../../../types";
export const planV0ToBasePriceProductItem = ({
ctx,
plan,
}: {
ctx: SharedContext;
plan: {
features: ApiPlanItemV0[];
price: ApiPlan["price"];
};
}): ProductItem | undefined => {
if (!plan.price) return;
const basePrice = plan.price;
const basePriceDisplay =
"display" in basePrice ? basePrice.display : undefined;
if (basePrice) {
const item = {
type: ProductItemType.Price,
feature_id: null,
feature: null,
interval: billingToItemInterval({
billingInterval: basePrice.interval ?? BillingInterval.Month,
}),
interval_count: basePrice.interval_count ?? 1,
price: basePrice.amount ?? 0,
} satisfies ProductItem;
const display =
basePriceDisplay ??
getProductItemDisplay({ item, features: ctx.features });
return {
...item,
display,
};
}
};

Some files were not shown because too many files have changed in this diff Show More