fix: logic in billingPlanToNextCyclePreview not using updated customer product
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user