fix: diff interval upgrade
This commit is contained in:
@@ -5,6 +5,8 @@ import {
|
||||
type FullProduct,
|
||||
findMainActiveCustomerProductByGroup,
|
||||
findMainScheduledCustomerProductByGroup,
|
||||
getProductBaseInterval,
|
||||
intervalsDifferent,
|
||||
isOneOffProduct,
|
||||
isProductUpgrade,
|
||||
} from "@autumn/shared";
|
||||
@@ -53,12 +55,30 @@ export const setupAttachTransitionContext = ({
|
||||
cusProduct: currentCustomerProduct,
|
||||
});
|
||||
|
||||
const isUpgrade = isProductUpgrade({
|
||||
prices1: currentPrices,
|
||||
prices2: attachProduct.prices,
|
||||
const currentBaseInterval = getProductBaseInterval({
|
||||
prices: currentPrices,
|
||||
});
|
||||
const newBaseInterval = getProductBaseInterval({
|
||||
prices: attachProduct.prices,
|
||||
});
|
||||
|
||||
planTiming = isUpgrade ? "immediate" : "end_of_cycle";
|
||||
const baseIntervalsAreDifferent =
|
||||
currentBaseInterval &&
|
||||
newBaseInterval &&
|
||||
intervalsDifferent({
|
||||
intervalA: currentBaseInterval,
|
||||
intervalB: newBaseInterval,
|
||||
});
|
||||
|
||||
if (baseIntervalsAreDifferent) {
|
||||
planTiming = "immediate";
|
||||
} else {
|
||||
const isUpgrade = isProductUpgrade({
|
||||
prices1: currentPrices,
|
||||
prices2: attachProduct.prices,
|
||||
});
|
||||
planTiming = isUpgrade ? "immediate" : "end_of_cycle";
|
||||
}
|
||||
}
|
||||
|
||||
// Override if plan_schedule param is provided
|
||||
|
||||
@@ -556,3 +556,89 @@ test.concurrent(`${chalk.yellowBright("immediate-switch-basic 6: invoice line it
|
||||
notPresent: [pro.id],
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 7: Pro Annual to Premium Monthly (interval change = immediate)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer has pro annual ($200/year)
|
||||
* - Switch to premium monthly ($50/mo)
|
||||
*
|
||||
* Expected Result:
|
||||
* - Premium is active immediately (different billing interval = always immediate)
|
||||
* - Pro annual is removed
|
||||
* - Credit for unused annual applied against premium charge
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("immediate-switch-basic 7: pro annual to premium monthly (interval change)")}`, async () => {
|
||||
const customerId = "imm-switch-pro-annual-to-premium";
|
||||
|
||||
const proAnnualMessages = items.monthlyMessages({ includedUsage: 500 });
|
||||
const proAnnual = products.proAnnual({
|
||||
id: "pro-annual",
|
||||
items: [proAnnualMessages],
|
||||
});
|
||||
|
||||
const premiumMessages = items.monthlyMessages({ includedUsage: 1000 });
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [premiumMessages],
|
||||
});
|
||||
|
||||
const { autumnV1, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [proAnnual, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: proAnnual.id })],
|
||||
});
|
||||
|
||||
// 1. Preview switch to premium monthly
|
||||
// At start of cycle: credit for full annual ($200) exceeds premium monthly ($50)
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
});
|
||||
expect(preview.total).toBe(0);
|
||||
|
||||
// 2. Attach premium (immediate because interval differs: annual -> monthly)
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Premium should be active, pro annual should be gone (immediate switch)
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [premium.id],
|
||||
notPresent: [proAnnual.id],
|
||||
});
|
||||
|
||||
// Verify messages feature has premium's balance
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 1000,
|
||||
balance: 1000,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify invoices: proAnnual ($200) + switch invoice ($0, credit exceeds charge)
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
latestTotal: 0,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -344,13 +344,15 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-cross 3: entity
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Entity 1: Premium Annual → Pro (scheduled)
|
||||
* - Entity 2: Premium Monthly → Pro (scheduled)
|
||||
* - Entity 1: Premium Annual → Pro (immediate, interval change)
|
||||
* - Entity 2: Premium Monthly → Pro (scheduled, same interval)
|
||||
* - Advance 1 month (monthly cycle ends)
|
||||
* - Upgrade entity 2 back to premium
|
||||
*
|
||||
* Expected Result:
|
||||
* - After cycle: Entity 1 still on annual (hasn't ended yet), Entity 2 on pro
|
||||
* - Entity 1 immediately switches to pro (annual→monthly = different interval = immediate)
|
||||
* - Entity 2 has premium canceling, pro scheduled (same interval = end_of_cycle)
|
||||
* - After cycle: Entity 1 on pro (renewed), Entity 2 on pro (scheduled switch completed)
|
||||
* - After upgrade: Entity 2 on premium
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-cross 4: entity 1 premiumAnnual to pro, entity 2 premium to pro, advance cycle, upgrade entity 2 to premium")}`, async () => {
|
||||
@@ -385,28 +387,27 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-cross 4: entity
|
||||
actions: [
|
||||
s.billing.attach({ productId: premiumAnnual.id, entityIndex: 0 }),
|
||||
s.billing.attach({ productId: premium.id, entityIndex: 1 }),
|
||||
s.billing.attach({ productId: pro.id, entityIndex: 0 }), // Downgrade entity 1 (annual)
|
||||
s.billing.attach({ productId: pro.id, entityIndex: 1 }), // Downgrade entity 2 (monthly)
|
||||
s.billing.attach({ productId: pro.id, entityIndex: 0 }), // Entity 1: immediate (interval change)
|
||||
s.billing.attach({ productId: pro.id, entityIndex: 1 }), // Entity 2: scheduled (same interval)
|
||||
s.advanceToNextInvoice(), // Advance 1 month
|
||||
],
|
||||
});
|
||||
|
||||
// Verify entity 1: still on annual (annual hasn't ended)
|
||||
// Entity 1 already switched to pro immediately (annual→monthly = different interval)
|
||||
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
// Entity 1's annual subscription should still be active with pro scheduled
|
||||
await expectProductCanceling({
|
||||
customer: entity1Before,
|
||||
productId: premiumAnnual.id,
|
||||
});
|
||||
await expectProductScheduled({
|
||||
await expectProductActive({
|
||||
customer: entity1Before,
|
||||
productId: pro.id,
|
||||
});
|
||||
await expectProductNotPresent({
|
||||
customer: entity1Before,
|
||||
productId: premiumAnnual.id,
|
||||
});
|
||||
|
||||
// Verify entity 2: now on pro (monthly cycle completed)
|
||||
// Entity 2: now on pro (monthly cycle completed, scheduled switch took effect)
|
||||
const entity2Before = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
|
||||
@@ -11,6 +11,16 @@ import { BillingType } from "../../../models/productModels/priceModels/priceEnum
|
||||
import type { Price } from "../../../models/productModels/priceModels/priceModels";
|
||||
import { getBillingType } from "../priceUtils";
|
||||
|
||||
/** Returns the billing interval of the base (fixed) price for a set of prices, or null if none found. */
|
||||
export const getProductBaseInterval = ({ prices }: { prices: Price[] }) => {
|
||||
const basePrice = prices.find(isFixedPrice);
|
||||
if (!basePrice) return null;
|
||||
return {
|
||||
interval: basePrice.config.interval as BillingInterval,
|
||||
intervalCount: basePrice.config.interval_count ?? 1,
|
||||
};
|
||||
};
|
||||
|
||||
export const isOneOffPrice = (
|
||||
price: Price,
|
||||
): price is Price & {
|
||||
|
||||
Reference in New Issue
Block a user