fix: added free trial param changed goes to update plan branch
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
type FeatureOptions,
|
||||
type FullCusProduct,
|
||||
type FullProduct,
|
||||
formatPrice,
|
||||
isPrepaidPrice,
|
||||
priceToFeature,
|
||||
type UpdateSubscriptionV0Params,
|
||||
@@ -27,6 +28,16 @@ export const setupFeatureQuantitiesContext = ({
|
||||
}): FeatureOptions[] => {
|
||||
const options: FeatureOptions[] = [];
|
||||
|
||||
console.log(
|
||||
"Full product prices:",
|
||||
fullProduct.prices.map((price) => formatPrice({ price })),
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Current customer product options:",
|
||||
currentCustomerProduct?.options,
|
||||
);
|
||||
|
||||
for (const price of fullProduct.prices) {
|
||||
if (!isPrepaidPrice(price)) continue;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { UpdateSubscriptionV0Params } from "@shared/index";
|
||||
import { notNullish } from "@/utils/genUtils";
|
||||
|
||||
export enum UpdateSubscriptionIntent {
|
||||
UpdateQuantity = "update_quantity",
|
||||
@@ -11,11 +12,13 @@ export enum UpdateSubscriptionIntent {
|
||||
export const computeUpdateSubscriptionIntent = (
|
||||
params: UpdateSubscriptionV0Params,
|
||||
): UpdateSubscriptionIntent => {
|
||||
const itemsChanged = notNullish(params.items);
|
||||
const versionChanged = notNullish(params.version);
|
||||
const freeTrialChanged = params.free_trial !== undefined;
|
||||
|
||||
if (itemsChanged || versionChanged || freeTrialChanged)
|
||||
return UpdateSubscriptionIntent.UpdatePlan;
|
||||
|
||||
// Version change = plan update (takes priority)
|
||||
if (params.version !== undefined) return UpdateSubscriptionIntent.UpdatePlan;
|
||||
|
||||
if (params.options?.length && !params.items?.length)
|
||||
return UpdateSubscriptionIntent.UpdateQuantity;
|
||||
|
||||
return UpdateSubscriptionIntent.UpdatePlan;
|
||||
return UpdateSubscriptionIntent.UpdateQuantity;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared";
|
||||
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
|
||||
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan";
|
||||
import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse";
|
||||
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
|
||||
import { computeUpdateSubscriptionPlan } from "./compute/computeUpdateSubscriptionPlan";
|
||||
import { logUpdateSubscriptionContext } from "./logs/logUpdateSubscriptionContext";
|
||||
import { logUpdateSubscriptionPlan } from "./logs/logUpdateSubscriptionPlan";
|
||||
import { setupUpdateSubscriptionBillingContext } from "./setup/setupUpdateSubscriptionBillingContext";
|
||||
|
||||
export const handlePreviewUpdateSubscription = createRoute({
|
||||
@@ -11,23 +14,41 @@ export const handlePreviewUpdateSubscription = createRoute({
|
||||
const ctx = c.get("ctx");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
ctx.logger.info(
|
||||
`=============== RUNNING PREVIEW UPDATE SUBSCRIPTION FOR ${body.customer_id} ===============`,
|
||||
);
|
||||
|
||||
const updateSubscriptionBillingContext =
|
||||
await setupUpdateSubscriptionBillingContext({
|
||||
ctx,
|
||||
params: body,
|
||||
});
|
||||
logUpdateSubscriptionContext({
|
||||
ctx,
|
||||
billingContext: updateSubscriptionBillingContext,
|
||||
});
|
||||
|
||||
const autumnBillingPlan = await computeUpdateSubscriptionPlan({
|
||||
ctx,
|
||||
billingContext: updateSubscriptionBillingContext,
|
||||
params: body,
|
||||
});
|
||||
logUpdateSubscriptionPlan({
|
||||
ctx,
|
||||
plan: autumnBillingPlan,
|
||||
billingContext: updateSubscriptionBillingContext,
|
||||
});
|
||||
|
||||
const stripeBillingPlan = await evaluateStripeBillingPlan({
|
||||
ctx,
|
||||
billingContext: updateSubscriptionBillingContext,
|
||||
autumnBillingPlan,
|
||||
});
|
||||
logStripeBillingPlan({
|
||||
ctx,
|
||||
stripeBillingPlan,
|
||||
billingContext: updateSubscriptionBillingContext,
|
||||
});
|
||||
|
||||
const previewResponse = billingPlanToPreviewResponse({
|
||||
ctx,
|
||||
|
||||
@@ -20,11 +20,11 @@ import chalk from "chalk";
|
||||
* Tests for scenarios involving products with prepaid/paid features and trial transitions.
|
||||
*/
|
||||
|
||||
// 1. Pro product with prepaid users -> add trial -> remove trial
|
||||
test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add trial then remove")}`, async () => {
|
||||
// 1. Pro product with prepaid users (0 seats) -> add trial -> update quantity to 5 seats
|
||||
test.concurrent(`${chalk.yellowBright("trial-paid-features: add trial then update quantity")}`, async () => {
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const priceItem = items.monthlyPrice({ price: 20 });
|
||||
const prepaidUsersItem = items.prepaidUsers({ includedUsage: 5 });
|
||||
const prepaidUsersItem = items.prepaidUsers({ includedUsage: 0 }); // $10/seat
|
||||
|
||||
const pro = products.base({
|
||||
id: "pro",
|
||||
@@ -32,7 +32,7 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
|
||||
customerId: "trial-prepaid-add-remove",
|
||||
customerId: "trial-prepaid-add-update-qty",
|
||||
setup: [
|
||||
s.customer({ testClock: true, paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
@@ -40,14 +40,12 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr
|
||||
actions: [
|
||||
s.attach({
|
||||
productId: pro.id,
|
||||
options: [{ feature_id: TestFeature.Users, quantity: 3 }],
|
||||
options: [{ feature_id: TestFeature.Users, quantity: 0 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return;
|
||||
|
||||
// Verify initial state - NOT trialing, has users
|
||||
// Verify initial state - NOT trialing, 0 seats
|
||||
const customerBefore =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductNotTrialing({
|
||||
@@ -55,12 +53,12 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
// Prepaid users: 5 included + 3 purchased = 8 total
|
||||
// Prepaid users: 0 included + 0 purchased = 0 total
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerBefore,
|
||||
featureId: TestFeature.Users,
|
||||
includedUsage: 5,
|
||||
balance: 8,
|
||||
includedUsage: 0,
|
||||
balance: 0,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
@@ -79,10 +77,10 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr
|
||||
const addTrialPreview =
|
||||
await autumnV1.subscriptions.previewUpdate(addTrialParams);
|
||||
|
||||
// Should refund previous payment since entering trial
|
||||
expect(addTrialPreview.total).toBeLessThanOrEqual(0);
|
||||
// Should refund previous payment since entering trial (base price only, 0 seats)
|
||||
expect(addTrialPreview.total).toEqual(-priceItem.price!);
|
||||
|
||||
// next_cycle should show when trial ends
|
||||
// next_cycle should show when trial ends (base price only, 0 seats)
|
||||
expectPreviewNextCycleCorrect({
|
||||
preview: addTrialPreview,
|
||||
startsAt: advancedTo + ms.days(14),
|
||||
@@ -91,6 +89,8 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr
|
||||
|
||||
await autumnV1.subscriptions.update(addTrialParams);
|
||||
|
||||
return;
|
||||
|
||||
const customerWithTrial =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
@@ -101,12 +101,12 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr
|
||||
trialEndsAt: advancedTo + ms.days(14),
|
||||
});
|
||||
|
||||
// Users should still be accessible
|
||||
// Users should still be 0
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerWithTrial,
|
||||
featureId: TestFeature.Users,
|
||||
includedUsage: 5,
|
||||
balance: 8,
|
||||
includedUsage: 0,
|
||||
balance: 0,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
@@ -119,54 +119,60 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Step 3: Update plan to remove the trial
|
||||
const removeTrialParams = {
|
||||
// Step 3: Update quantity to 5 seats while trialing
|
||||
const seatsQuantity = 5;
|
||||
const seatsPrice = seatsQuantity * 10; // $10/seat = $50
|
||||
|
||||
const updateQuantityParams = {
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
free_trial: null,
|
||||
options: [{ feature_id: TestFeature.Users, quantity: seatsQuantity }],
|
||||
};
|
||||
|
||||
const removeTrialPreview =
|
||||
await autumnV1.subscriptions.previewUpdate(removeTrialParams);
|
||||
const updateQuantityPreview =
|
||||
await autumnV1.subscriptions.previewUpdate(updateQuantityParams);
|
||||
|
||||
// Should charge full price since trial is being removed
|
||||
expect(removeTrialPreview.total).toEqual(priceItem.price);
|
||||
// Should be $0 during trial (seats are free during trial)
|
||||
expect(updateQuantityPreview.total).toEqual(0);
|
||||
|
||||
// When trial is removed, next_cycle should not be defined (billing starts now)
|
||||
// next_cycle should show when trial ends (base price + 5 seats)
|
||||
expectPreviewNextCycleCorrect({
|
||||
preview: removeTrialPreview,
|
||||
expectDefined: false,
|
||||
preview: updateQuantityPreview,
|
||||
startsAt: advancedTo + ms.days(14),
|
||||
total: priceItem.price! + seatsPrice,
|
||||
});
|
||||
|
||||
await autumnV1.subscriptions.update(removeTrialParams, { timeout: 5000 });
|
||||
await autumnV1.subscriptions.update(updateQuantityParams);
|
||||
return;
|
||||
|
||||
const customerAfterRemove =
|
||||
const customerAfterQuantityUpdate =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Product should no longer be trialing
|
||||
await expectProductNotTrialing({
|
||||
customer: customerAfterRemove,
|
||||
// Product should still be trialing
|
||||
await expectProductTrialing({
|
||||
customer: customerAfterQuantityUpdate,
|
||||
productId: pro.id,
|
||||
trialEndsAt: advancedTo + ms.days(14),
|
||||
});
|
||||
|
||||
// Should now be active (not trialing)
|
||||
// Should now be active (trialing)
|
||||
await expectProductActive({
|
||||
customer: customerAfterRemove,
|
||||
customer: customerAfterQuantityUpdate,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
// Users should still be accessible with same balance
|
||||
// Users should now have 5 seats
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfterRemove,
|
||||
customer: customerAfterQuantityUpdate,
|
||||
featureId: TestFeature.Users,
|
||||
includedUsage: 5,
|
||||
balance: 8,
|
||||
includedUsage: 0,
|
||||
balance: seatsQuantity,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Messages should still be accessible
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfterRemove,
|
||||
customer: customerAfterQuantityUpdate,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: messagesItem.included_usage,
|
||||
balance: messagesItem.included_usage,
|
||||
@@ -178,8 +184,5 @@ test.concurrent(`${chalk.yellowBright("trial-paid-features: prepaid users add tr
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
flags: {
|
||||
checkNotTrialing: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -735,5 +735,48 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].quantity).toBe(75);
|
||||
});
|
||||
|
||||
test("current has options with quantity 0, new params has no options → carries over 0", () => {
|
||||
const feature = features.create({
|
||||
id: "seats",
|
||||
name: "Seats",
|
||||
});
|
||||
|
||||
const price = prices.createPrepaid({
|
||||
id: "price_seats",
|
||||
featureId: "seats",
|
||||
});
|
||||
|
||||
const fullProduct = products.createFull({ prices: [price] });
|
||||
const cusProduct = customerProducts.create({
|
||||
options: [
|
||||
{
|
||||
feature_id: "seats",
|
||||
internal_feature_id: "internal_seats",
|
||||
quantity: 0,
|
||||
},
|
||||
],
|
||||
customerPrices: [prices.createCustomer({ price })],
|
||||
});
|
||||
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
// No options provided - should carry over from current
|
||||
};
|
||||
|
||||
const ctx = contexts.create({ features: [feature] });
|
||||
|
||||
const result = setupFeatureQuantitiesContext({
|
||||
ctx,
|
||||
featureQuantitiesParams: params,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature_id).toBe("seats");
|
||||
expect(result[0].quantity).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type { Feature } from "@models/featureModels/featureModels";
|
||||
import type { Price } from "@models/productModels/priceModels/priceModels";
|
||||
import { roundUsageToNearestBillingUnit } from "@utils/billingUtils/usageUtils/roundUsageToNearestBillingUnit";
|
||||
import { findPrepaidCusPriceByFeature } from "@utils/cusPriceUtils/findCusPriceUtils/findPrepaidCusPriceByFeature";
|
||||
import { nullish } from "@utils/utils";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { cusProductToFeatureOptions } from "./cusProductToFeatureOptions";
|
||||
|
||||
@@ -23,7 +24,7 @@ export const cusProductToConvertedFeatureOptions = ({
|
||||
}): FeatureOptions | undefined => {
|
||||
const currentOption = cusProductToFeatureOptions({ cusProduct, feature });
|
||||
|
||||
if (!currentOption?.quantity) return undefined;
|
||||
if (nullish(currentOption?.quantity)) return undefined;
|
||||
|
||||
const oldCusPrice = findPrepaidCusPriceByFeature({
|
||||
customerPrices: cusProduct.customer_prices,
|
||||
|
||||
Reference in New Issue
Block a user