fix: logic in billingPlanToNextCyclePreview not using updated customer product

This commit is contained in:
John Yeo
2026-01-15 19:15:32 +00:00
parent ec38897566
commit 27810193eb
9 changed files with 279 additions and 17 deletions

View File

@@ -3,8 +3,11 @@
# Source shared configuration
source "$(dirname "$0")/config.sh"
bun test:integration update-subscription/custom-plan
bun test:integration update-subscription/discounts
# Exit immediately if a command exits with a non-zero status
set -e
# bun test:integration update-subscription/custom-plan
# bun test:integration update-subscription/discounts
bun test:integration update-subscription/errors
bun test:integration update-subscription/free-trial
bun test:integration update-subscription/invoice

View File

@@ -5,6 +5,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan";
import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition";
import { billingPlanToUpdatedCustomerProduct } from "@/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct";
import { customerProductToLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToLineItems";
const formatLineItem = (item: LineItem) => ({
@@ -69,9 +70,13 @@ const getSiblingCustomerProducts = ({
autumnBillingPlan: AutumnBillingPlan;
stripeSubscriptionId: string;
}): FullCusProduct[] => {
const updatedCustomerProduct = billingPlanToUpdatedCustomerProduct({
autumnBillingPlan,
});
const handledIds = new Set([
...autumnBillingPlan.insertCustomerProducts.map((cp) => cp.id),
autumnBillingPlan.updateCustomerProduct?.customerProduct.id,
updatedCustomerProduct?.id,
autumnBillingPlan.deleteCustomerProduct?.id,
]);

View File

@@ -1,5 +1,6 @@
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { billingPlanToUpdatedCustomerProduct } from "@/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct";
export const autumnBillingPlanToFinalFullCustomer = ({
billingContext,
@@ -9,7 +10,6 @@ export const autumnBillingPlanToFinalFullCustomer = ({
autumnBillingPlan: AutumnBillingPlan;
}) => {
const {
updateCustomerProduct,
deleteCustomerProduct,
insertCustomerProducts,
updateCustomerEntitlements,
@@ -24,12 +24,9 @@ export const autumnBillingPlanToFinalFullCustomer = ({
];
// 2. Replace updated customer product if applicable
const updatedCustomerProduct = updateCustomerProduct
? {
...updateCustomerProduct.customerProduct,
...updateCustomerProduct.updates,
}
: undefined;
const updatedCustomerProduct = billingPlanToUpdatedCustomerProduct({
autumnBillingPlan,
});
let customerProducts = combinedCustomerProducts.map((customerProduct) =>
customerProduct.id === updatedCustomerProduct?.id

View File

@@ -9,6 +9,7 @@ import {
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { billingPlanToUpdatedCustomerProduct } from "@/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct";
import { customerProductToLineItems } from "../lineItems/customerProductToLineItems";
export const billingPlanToNextCyclePreview = ({
@@ -24,15 +25,16 @@ export const billingPlanToNextCyclePreview = ({
const { billingCycleAnchorMs } = billingContext;
if (billingCycleAnchorMs === "now") return undefined;
const {
insertCustomerProducts,
updateCustomerProduct: { customerProduct: targetUpdateCustomerProduct },
} = billingPlan.autumn;
const updatedCustomerProduct = billingPlanToUpdatedCustomerProduct({
autumnBillingPlan: billingPlan.autumn,
});
const { insertCustomerProducts } = billingPlan.autumn;
// 2. Get cycle end and if none, return undefined
const allCustomerProducts = [
...insertCustomerProducts,
...(targetUpdateCustomerProduct ? [targetUpdateCustomerProduct] : []),
...(updatedCustomerProduct ? [updatedCustomerProduct] : []),
];
const customerProducts = allCustomerProducts.filter(
(customerProduct) =>

View File

@@ -0,0 +1,17 @@
import type { FullCusProduct } from "@autumn/shared";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan";
export const billingPlanToUpdatedCustomerProduct = ({
autumnBillingPlan,
}: {
autumnBillingPlan: AutumnBillingPlan;
}): FullCusProduct | undefined => {
const { updateCustomerProduct } = autumnBillingPlan;
if (!updateCustomerProduct) return undefined;
return {
...updateCustomerProduct.customerProduct,
...updateCustomerProduct.updates,
};
};

View File

@@ -0,0 +1,49 @@
import {
ProductNotFoundError,
type ProductV2,
productsAreSame,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { ProductService } from "@/internal/products/ProductService";
export const handlePlanHasCustomersV2 = createRoute({
handler: async (c) => {
const { product_id } = c.req.param();
const ctx = c.get("ctx");
const { db, features, org, env } = ctx;
const body = await c.req.json();
const product = await ProductService.getFull({
db,
idOrInternalId: product_id,
orgId: org.id,
env: env,
});
if (!product) {
throw new ProductNotFoundError({ productId: product_id });
}
const cusProductsCurVersion =
await CusProductService.getByInternalProductId({
db,
internalProductId: product.internal_id,
});
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2: body as ProductV2,
curProductV1: product,
features,
});
const productSame = itemsSame && freeTrialsSame;
return c.json({
current_version: product.version,
will_version: !productSame && cusProductsCurVersion.length > 0,
archived: product.archived,
});
},
});

View File

@@ -1,6 +1,7 @@
import express from "express";
import { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handlePlanHasCustomersV2 } from "@/internal/products/handlers/handlePlanHasCustomersV2.js";
import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js";
import { handleCreatePlan } from "./handlers/handleCreatePlan.js";
import { handleDeleteProduct as handleDeleteProductHono } from "./handlers/handleDeleteProduct.js";
@@ -36,5 +37,8 @@ honoProductRouter.delete("/:product_id", ...handleDeleteProductHono);
honoProductRouter.post("/:product_id/copy", ...handleCopyProductV2);
// Info before deleting plan
// honoProductRouter.get("/:product_id/has_customers", ...handlePlanHasCustomers);
honoProductRouter.post(
"/:product_id/has_customers",
...handlePlanHasCustomersV2,
);
honoProductRouter.get("/:product_id/deletion_info", ...handleGetPlanDeleteInfo);

View File

@@ -41,7 +41,7 @@ test.concurrent(`${chalk.yellowBright("one-off: update included usage on free me
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOffProduct] }),
],
actions: [s.attach({ productId: oneOffProduct.id })],
actions: [s.attach({ productId: oneOffProduct.id, timeout: 3000 })],
});
// Track some usage

View File

@@ -0,0 +1,185 @@
import { expect, test } from "bun:test";
import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
/**
* Update Trial with Paid Features Tests
*
* 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 () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const prepaidUsersItem = items.prepaidUsers({ includedUsage: 5 });
const pro = products.base({
id: "pro",
items: [messagesItem, priceItem, prepaidUsersItem],
});
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "trial-prepaid-add-remove",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Users, quantity: 3 }],
}),
],
});
return;
// Verify initial state - NOT trialing, has users
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotTrialing({
customer: customerBefore,
productId: pro.id,
});
// Prepaid users: 5 included + 3 purchased = 8 total
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Users,
includedUsage: 5,
balance: 8,
usage: 0,
});
// Step 2: Update plan to start a free trial
const addTrialParams = {
customer_id: customerId,
product_id: pro.id,
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
};
const addTrialPreview =
await autumnV1.subscriptions.previewUpdate(addTrialParams);
// Should refund previous payment since entering trial
expect(addTrialPreview.total).toBeLessThanOrEqual(0);
// next_cycle should show when trial ends
expectPreviewNextCycleCorrect({
preview: addTrialPreview,
startsAt: advancedTo + ms.days(14),
total: priceItem.price!,
});
await autumnV1.subscriptions.update(addTrialParams);
const customerWithTrial =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Product should now be trialing
await expectProductTrialing({
customer: customerWithTrial,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Users should still be accessible
expectCustomerFeatureCorrect({
customer: customerWithTrial,
featureId: TestFeature.Users,
includedUsage: 5,
balance: 8,
usage: 0,
});
// Messages should still be accessible
expectCustomerFeatureCorrect({
customer: customerWithTrial,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage,
usage: 0,
});
// Step 3: Update plan to remove the trial
const removeTrialParams = {
customer_id: customerId,
product_id: pro.id,
free_trial: null,
};
const removeTrialPreview =
await autumnV1.subscriptions.previewUpdate(removeTrialParams);
// Should charge full price since trial is being removed
expect(removeTrialPreview.total).toEqual(priceItem.price);
// When trial is removed, next_cycle should not be defined (billing starts now)
expectPreviewNextCycleCorrect({
preview: removeTrialPreview,
expectDefined: false,
});
await autumnV1.subscriptions.update(removeTrialParams, { timeout: 5000 });
const customerAfterRemove =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Product should no longer be trialing
await expectProductNotTrialing({
customer: customerAfterRemove,
productId: pro.id,
});
// Should now be active (not trialing)
await expectProductActive({
customer: customerAfterRemove,
productId: pro.id,
});
// Users should still be accessible with same balance
expectCustomerFeatureCorrect({
customer: customerAfterRemove,
featureId: TestFeature.Users,
includedUsage: 5,
balance: 8,
usage: 0,
});
// Messages should still be accessible
expectCustomerFeatureCorrect({
customer: customerAfterRemove,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage,
usage: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: {
checkNotTrialing: true,
},
});
});