fix: added free trial param changed goes to update plan branch

This commit is contained in:
John Yeo
2026-01-15 20:06:28 +00:00
parent a70ee74828
commit af0476ceb2
6 changed files with 131 additions and 49 deletions

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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,

View File

@@ -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,
},
});
});

View File

@@ -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);
});
});
});

View File

@@ -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,