feat: new attach / update subscription params
This commit is contained in:
38
server/src/external/autumn/autumnCli.ts
vendored
38
server/src/external/autumn/autumnCli.ts
vendored
@@ -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",
|
||||
|
||||
@@ -122,6 +122,7 @@ export const versionedValidator = ({
|
||||
fromVersion: userVersion,
|
||||
toVersion: new ApiVersionClass(LATEST_VERSION),
|
||||
resource,
|
||||
ctx,
|
||||
});
|
||||
|
||||
// Replace validated data with transformed version
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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"
|
||||
? {
|
||||
|
||||
@@ -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"
|
||||
? {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
? {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
133
server/tests/utils/fixtures/itemsV2.ts
Normal file
133
server/tests/utils/fixtures/itemsV2.ts
Normal 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;
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
48
shared/api/billing/attachV2/attachParamsV1.ts
Normal file
48
shared/api/billing/attachV2/attachParamsV1.ts
Normal 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>;
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -28,7 +28,6 @@ export const BillingResponseSchema = z.object({
|
||||
.optional(),
|
||||
|
||||
payment_url: z.string().nullable(),
|
||||
|
||||
required_action: BillingResponseRequiredActionSchema.optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
14
shared/api/billing/common/customizePlan/customizePlanV1.ts
Normal file
14
shared/api/billing/common/customizePlan/customizePlanV1.ts
Normal 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>;
|
||||
@@ -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];
|
||||
}
|
||||
};
|
||||
4
shared/api/billing/common/redirectMode.ts
Normal file
4
shared/api/billing/common/redirectMode.ts
Normal 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>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
|
||||
@@ -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"
|
||||
20
shared/api/common/freeTrial/mappers/freeTrialParamsV0ToV1.ts
Normal file
20
shared/api/common/freeTrial/mappers/freeTrialParamsV0ToV1.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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 });
|
||||
|
||||
26
shared/api/products/components/basePrice/basePrice.ts
Normal file
26
shared/api/products/components/basePrice/basePrice.ts
Normal 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>;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user