feat: 🎸 carry_over_usages wired into attach v2
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import type { AttachBillingContext } from "@autumn/shared";
|
||||
import type { AttachBillingContext, AttachParamsV1 } from "@autumn/shared";
|
||||
import {
|
||||
CusProductStatus,
|
||||
deduplicateArray,
|
||||
type FullCusProduct,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { applyCarryOverUsageFeatureIds } from "@/internal/billing/v2/utils/handleCarryOvers/carryOverUtils";
|
||||
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
|
||||
|
||||
/**
|
||||
@@ -16,9 +17,11 @@ import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCus
|
||||
export const computeAttachNewCustomerProduct = ({
|
||||
ctx,
|
||||
attachBillingContext,
|
||||
params = {} as AttachParamsV1,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
attachBillingContext: AttachBillingContext;
|
||||
params?: AttachParamsV1;
|
||||
}): FullCusProduct => {
|
||||
const {
|
||||
attachProduct,
|
||||
@@ -41,6 +44,7 @@ export const computeAttachNewCustomerProduct = ({
|
||||
const currentCustomerEntitlements =
|
||||
currentCustomerProduct?.customer_entitlements ?? [];
|
||||
|
||||
// LEGACY: carry_from_previous flag on entitlements
|
||||
const featuresToCarryUsagesFor = deduplicateArray(
|
||||
currentCustomerEntitlements
|
||||
.filter((ce) => {
|
||||
@@ -49,6 +53,13 @@ export const computeAttachNewCustomerProduct = ({
|
||||
.map((ce) => ce.entitlement.feature.id),
|
||||
);
|
||||
|
||||
// carry_over_usages param override — replaces legacy list with consumable feature ids if necessary
|
||||
const consumableFeatureIdsToCarry = applyCarryOverUsageFeatureIds({
|
||||
params,
|
||||
currentCustomerEntitlements,
|
||||
featuresToCarryUsagesFor,
|
||||
});
|
||||
|
||||
// Determine if this is a scheduled product (downgrade)
|
||||
const isScheduled = planTiming === "end_of_cycle";
|
||||
|
||||
@@ -56,7 +67,7 @@ export const computeAttachNewCustomerProduct = ({
|
||||
!isScheduled && currentCustomerProduct
|
||||
? {
|
||||
fromCustomerProduct: currentCustomerProduct,
|
||||
consumableFeatureIdsToCarry: featuresToCarryUsagesFor,
|
||||
consumableFeatureIdsToCarry,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export const computeAttachPlan = ({
|
||||
const newCustomerProduct = computeAttachNewCustomerProduct({
|
||||
ctx,
|
||||
attachBillingContext,
|
||||
params,
|
||||
});
|
||||
|
||||
const updateCustomerProduct = computeAttachTransitionUpdates({
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { handleAttachInvoiceModeErrors } from "@/internal/billing/v2/actions/attach/errors/handleAttachInvoiceModeErrors";
|
||||
import { handleCarryOverBalancesErrors } from "@/internal/billing/v2/actions/attach/errors/handleCarryOverBalancesErrors";
|
||||
import { handleCarryOverUsagesErrors } from "@/internal/billing/v2/actions/attach/errors/handleCarryOverUsagesErrors";
|
||||
import { handleCurrentCustomerProductErrors } from "@/internal/billing/v2/actions/attach/errors/handleCurrentCustomerProductErrors";
|
||||
import { handleNewBillingSubscriptionErrors } from "@/internal/billing/v2/actions/attach/errors/handleNewBillingSubscriptionErrors";
|
||||
import { handleScheduledSwitchOneOffErrors } from "@/internal/billing/v2/actions/attach/errors/handleScheduledSwitchOneOffErrors";
|
||||
@@ -57,7 +58,10 @@ export const handleAttachV2Errors = async ({
|
||||
// 8. Carry over balances errors (non-consumable features, downgrade block)
|
||||
handleCarryOverBalancesErrors({ ctx, params, billingContext });
|
||||
|
||||
// 9. Proration behavior errors (none restrictions)
|
||||
// 9. Carry over usages errors (non-consumable features, downgrade block)
|
||||
handleCarryOverUsagesErrors({ ctx, params, billingContext });
|
||||
|
||||
// 10. Proration behavior errors (none restrictions)
|
||||
handleProrationBehaviorErrors({
|
||||
billingContext,
|
||||
currentCustomerProduct: billingContext.currentCustomerProduct,
|
||||
@@ -65,14 +69,14 @@ export const handleAttachV2Errors = async ({
|
||||
params,
|
||||
});
|
||||
|
||||
// 9. Subscription ID uniqueness
|
||||
// 11. Subscription ID uniqueness
|
||||
await handleSubscriptionIdErrors({
|
||||
db: ctx.db,
|
||||
internalCustomerId: billingContext.fullCustomer.internal_id,
|
||||
subscriptionIds: [billingContext.externalId],
|
||||
});
|
||||
|
||||
// 9. Custom line items errors (only valid for subscription updates)
|
||||
// 12. Custom line items errors (only valid for subscription updates)
|
||||
handleCustomLineItemsErrors({
|
||||
params,
|
||||
billingContext,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { AttachBillingContext, AttachParamsV1 } from "@autumn/shared";
|
||||
import {
|
||||
ErrCode,
|
||||
featureUtils,
|
||||
isBooleanFeature,
|
||||
RecaseError,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
|
||||
/**
|
||||
* Validates carry_over_usages params.
|
||||
*
|
||||
* - Only valid for immediate switches — errors on scheduled/downgrade.
|
||||
* - Boolean and allocated (continuous_use) features cannot have usages carried over.
|
||||
*/
|
||||
export const handleCarryOverUsagesErrors = ({
|
||||
ctx,
|
||||
params,
|
||||
billingContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
params: AttachParamsV1;
|
||||
billingContext: AttachBillingContext;
|
||||
}) => {
|
||||
const carryOver = params.carry_over_usages;
|
||||
if (!carryOver?.enabled) return;
|
||||
|
||||
if (billingContext.planTiming !== "immediate") {
|
||||
throw new RecaseError({
|
||||
message:
|
||||
"carry_over_usages is only supported for immediate plan switches (upgrades). It cannot be used with scheduled downgrades.",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const featureIds = carryOver.feature_ids;
|
||||
if (!featureIds?.length) return;
|
||||
|
||||
for (const featureId of featureIds) {
|
||||
const feature = featureUtils.find.byId({
|
||||
features: ctx.features,
|
||||
featureId,
|
||||
errorOnNotFound: true,
|
||||
});
|
||||
|
||||
if (isBooleanFeature({ feature })) {
|
||||
throw new RecaseError({
|
||||
message: `carry_over_usages is not supported for boolean features. Feature '${featureId}' is a boolean (static) feature and does not have a consumable usage.`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (featureUtils.isAllocated(feature)) {
|
||||
throw new RecaseError({
|
||||
message: `carry_over_usages is not supported for non-consumable features. Feature '${featureId}' is a non-consumable (allocated) feature and does not have a consumable usage to carry over.`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { AttachParamsV1, FullCustomerEntitlement } from "@autumn/shared";
|
||||
import {
|
||||
deduplicateArray,
|
||||
featureUtils,
|
||||
isBooleanCusEnt,
|
||||
isUnlimitedCusEnt,
|
||||
} from "@autumn/shared";
|
||||
|
||||
/** Mutates featuresToCarryUsagesFor with consumable feature IDs from carry_over_usages params and returns it. */
|
||||
export const applyCarryOverUsageFeatureIds = ({
|
||||
params,
|
||||
currentCustomerEntitlements,
|
||||
featuresToCarryUsagesFor,
|
||||
}: {
|
||||
params: AttachParamsV1;
|
||||
currentCustomerEntitlements: FullCustomerEntitlement[];
|
||||
featuresToCarryUsagesFor: string[];
|
||||
}): string[] => {
|
||||
const carryOverUsages = params.carry_over_usages;
|
||||
if (!carryOverUsages?.enabled) return featuresToCarryUsagesFor;
|
||||
|
||||
const allConsumableFeatureIds = deduplicateArray(
|
||||
currentCustomerEntitlements
|
||||
.filter(
|
||||
(ce) =>
|
||||
!isBooleanCusEnt({ cusEnt: ce }) &&
|
||||
!isUnlimitedCusEnt(ce) &&
|
||||
!featureUtils.isAllocated(ce.entitlement.feature),
|
||||
)
|
||||
.map((ce) => ce.entitlement.feature.id),
|
||||
);
|
||||
|
||||
const overrideFeatureIds = carryOverUsages.feature_ids
|
||||
? allConsumableFeatureIds.filter((id) =>
|
||||
carryOverUsages.feature_ids!.includes(id),
|
||||
)
|
||||
: allConsumableFeatureIds;
|
||||
|
||||
featuresToCarryUsagesFor.splice(
|
||||
0,
|
||||
featuresToCarryUsagesFor.length,
|
||||
...overrideFeatureIds,
|
||||
);
|
||||
|
||||
return featuresToCarryUsagesFor;
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Carry Over Usages - Basic Tests
|
||||
*
|
||||
* Tests for carry_over_usages: { enabled: true } on immediate plan upgrades.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Existing usage is deducted from the new plan's allowance on upgrade
|
||||
* - Zero usage is a silent no-op (new plan starts at full allowance)
|
||||
* - New plan balance is clamped to zero — cannot go negative from carried usage
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import type { ApiCustomerV3 } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Basic usage carry over (deduction)
|
||||
//
|
||||
// Pro: 50 messages, 40 used (balance=10)
|
||||
// Upgrade to Premium (200) with carry_over_usages: { enabled: true }
|
||||
// Expected: balance = 160 (200 - 40 carried usage), usage = 40
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("carry-over-usage 1: existing usage is deducted from new plan allowance on upgrade")}`, async () => {
|
||||
const proMessages = items.monthlyMessages({ includedUsage: 50 });
|
||||
const premiumMessages = items.monthlyMessages({ includedUsage: 200 });
|
||||
|
||||
const pro = products.pro({ id: "pro", items: [proMessages] });
|
||||
const premium = products.premium({ id: "premium", items: [premiumMessages] });
|
||||
|
||||
const { customerId, autumnV2_1, autumnV1 } = await initScenario({
|
||||
customerId: "carry-over-usage-basic1",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id, timeout: 4000 })],
|
||||
});
|
||||
|
||||
// Track 40 units (balance: 50 → 10)
|
||||
await autumnV2_1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 40,
|
||||
});
|
||||
|
||||
// Wait for Redis → Postgres sync
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Upgrade to Premium with carry_over_usages enabled
|
||||
await autumnV2_1.billing.attach({
|
||||
customer_id: customerId,
|
||||
plan_id: premium.id,
|
||||
carry_over_usages: { enabled: true },
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Balance = 200 (Premium allowance) - 40 (carried usage) = 160
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: 160,
|
||||
usage: 40,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Zero usage — nothing to carry
|
||||
//
|
||||
// Pro: 50 messages, 0 used (balance=50, nothing consumed)
|
||||
// Upgrade to Premium (200) with carry_over_usages: { enabled: true }
|
||||
// Expected: balance = 200 (no deduction — usage was zero)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("carry-over-usage 2: zero usage is a no-op — new plan starts at full allowance")}`, async () => {
|
||||
const proMessages = items.monthlyMessages({ includedUsage: 50 });
|
||||
const premiumMessages = items.monthlyMessages({ includedUsage: 200 });
|
||||
|
||||
const pro = products.pro({ id: "pro", items: [proMessages] });
|
||||
const premium = products.premium({ id: "premium", items: [premiumMessages] });
|
||||
|
||||
const { customerId, autumnV2_1, autumnV1 } = await initScenario({
|
||||
customerId: "carry-over-usage-zero",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id, timeout: 4000 })],
|
||||
});
|
||||
|
||||
// No tracking — usage stays at 0
|
||||
|
||||
// Upgrade with carry_over_usages — zero usage is a silent no-op
|
||||
await autumnV2_1.billing.attach({
|
||||
customer_id: customerId,
|
||||
plan_id: premium.id,
|
||||
carry_over_usages: { enabled: true },
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Balance = 200 only (no deduction — nothing was used)
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: 200,
|
||||
usage: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Clamp to zero — usage exceeds new plan allowance
|
||||
//
|
||||
// Pro: 50 messages, 50 used (balance=0, all used)
|
||||
// Upgrade to Premium (30 messages) with carry_over_usages: { enabled: true }
|
||||
// Expected: balance = 0 (50 usage > 30 new allowance — clamped, not negative)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("carry-over-usage 3: balance is clamped to zero when carried usage exceeds new plan allowance")}`, async () => {
|
||||
const proMessages = items.monthlyMessages({ includedUsage: 50 });
|
||||
const premiumMessages = items.monthlyMessages({ includedUsage: 30 });
|
||||
|
||||
const pro = products.pro({ id: "pro", items: [proMessages] });
|
||||
const premium = products.premium({ id: "premium", items: [premiumMessages] });
|
||||
|
||||
const { customerId, autumnV2_1, autumnV1 } = await initScenario({
|
||||
customerId: "carry-over-usage-clamp",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id, timeout: 4000 })],
|
||||
});
|
||||
|
||||
// Exhaust all 50 allowance (balance: 50 → 0)
|
||||
await autumnV2_1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 50,
|
||||
});
|
||||
|
||||
// Wait for Redis → Postgres sync
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Upgrade with carry_over_usages — 50 usage > 30 new allowance, must clamp to 0
|
||||
await autumnV2_1.billing.attach({
|
||||
customer_id: customerId,
|
||||
plan_id: premium.id,
|
||||
carry_over_usages: { enabled: true },
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Balance = 0 (clamped — cannot go negative)
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: 0,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Carry Over Usages — Error Cases
|
||||
*
|
||||
* carry_over_usages validation:
|
||||
* - Scheduled/downgrade switches are blocked → InvalidRequest
|
||||
* - Allocated (continuous_use) features in feature_ids are blocked → InvalidRequest
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import { ErrCode } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Downgrade with carry_over_usages is blocked
|
||||
//
|
||||
// Premium: 500 messages, 100 used (balance=400)
|
||||
// Downgrade to Pro (100) with carry_over_usages: { enabled: true }
|
||||
// Expected: InvalidRequest — carry_over_usages only supports immediate upgrades
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("carry-over-usage-errors 1: carry_over_usages on scheduled downgrade returns InvalidRequest")}`, async () => {
|
||||
const premiumMessages = items.monthlyMessages({ includedUsage: 500 });
|
||||
const proMessages = items.monthlyMessages({ includedUsage: 100 });
|
||||
|
||||
const premium = products.premium({ id: "premium", items: [premiumMessages] });
|
||||
const pro = products.pro({ id: "pro", items: [proMessages] });
|
||||
|
||||
const { customerId, autumnV2_1 } = await initScenario({
|
||||
customerId: "carry-over-usage-err-downgrade",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [premium, pro] }),
|
||||
],
|
||||
actions: [s.attach({ productId: premium.id, timeout: 4000 })],
|
||||
});
|
||||
|
||||
// Track some usage so there's something to (not) carry
|
||||
await autumnV2_1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 100,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Downgrade with carry_over_usages — should be rejected
|
||||
await expectAutumnError({
|
||||
errCode: ErrCode.InvalidRequest,
|
||||
func: async () => {
|
||||
await autumnV2_1.billing.attach({
|
||||
customer_id: customerId,
|
||||
plan_id: pro.id,
|
||||
carry_over_usages: { enabled: true },
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Allocated feature in feature_ids → error
|
||||
//
|
||||
// Pro with allocated seats (prorated billing)
|
||||
// Attach Premium with carry_over_usages: { enabled: true, feature_ids: ["users"] }
|
||||
// Expected: InvalidRequest — allocated features have no consumable usage to carry
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("carry-over-usage-errors 2: allocated feature in feature_ids returns InvalidRequest")}`, async () => {
|
||||
const proItems = [
|
||||
items.monthlyMessages({ includedUsage: 100 }),
|
||||
items.allocatedUsers({ includedUsage: 5 }),
|
||||
];
|
||||
const premiumItems = [
|
||||
items.monthlyMessages({ includedUsage: 500 }),
|
||||
items.allocatedUsers({ includedUsage: 10 }),
|
||||
];
|
||||
|
||||
const pro = products.pro({ id: "pro", items: proItems });
|
||||
const premium = products.premium({ id: "premium", items: premiumItems });
|
||||
|
||||
const { customerId, autumnV2_1 } = await initScenario({
|
||||
customerId: "carry-over-usage-err-allocated",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id, timeout: 4000 })],
|
||||
});
|
||||
|
||||
await expectAutumnError({
|
||||
errCode: ErrCode.InvalidRequest,
|
||||
func: async () => {
|
||||
await autumnV2_1.billing.attach({
|
||||
customer_id: customerId,
|
||||
plan_id: premium.id,
|
||||
carry_over_usages: {
|
||||
enabled: true,
|
||||
feature_ids: [TestFeature.Users],
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Carry Over Usages — feature_ids Filter Tests
|
||||
*
|
||||
* When feature_ids is provided, only the listed features have their usage
|
||||
* carried over. All other consumable features start at their full new allowance.
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import type { ApiCustomerV3 } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: feature_ids filter — only listed feature has usage carried over
|
||||
//
|
||||
// Pro: 50 messages (30 used), 100 words (60 used)
|
||||
// Upgrade to Premium (200 messages, 300 words)
|
||||
// with carry_over_usages: { enabled: true, feature_ids: ["messages"] }
|
||||
// Expected:
|
||||
// messages: balance = 170 (200 - 30 carried usage), usage = 30
|
||||
// words: balance = 300 (full new allowance — words not in feature_ids), usage = 0
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("carry-over-usage-feature-ids 1: only feature_ids listed have usage carried — other features start at full new allowance")}`, async () => {
|
||||
const proMessages = items.monthlyMessages({ includedUsage: 50 });
|
||||
const proWords = items.monthlyWords({ includedUsage: 100 });
|
||||
|
||||
const premiumMessages = items.monthlyMessages({ includedUsage: 200 });
|
||||
const premiumWords = items.monthlyWords({ includedUsage: 300 });
|
||||
|
||||
const pro = products.pro({ id: "pro", items: [proMessages, proWords] });
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [premiumMessages, premiumWords],
|
||||
});
|
||||
|
||||
const { customerId, autumnV2_1, autumnV1 } = await initScenario({
|
||||
customerId: "carry-over-usage-feature-ids1",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id, timeout: 4000 })],
|
||||
});
|
||||
|
||||
// Track 30 messages and 60 words
|
||||
await autumnV2_1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 30,
|
||||
});
|
||||
await autumnV2_1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Words,
|
||||
value: 60,
|
||||
});
|
||||
|
||||
// Wait for Redis → Postgres sync
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Upgrade with carry_over_usages scoped to messages only
|
||||
await autumnV2_1.billing.attach({
|
||||
customer_id: customerId,
|
||||
plan_id: premium.id,
|
||||
carry_over_usages: {
|
||||
enabled: true,
|
||||
feature_ids: [TestFeature.Messages],
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// messages: 200 - 30 carried usage = 170
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: 170,
|
||||
usage: 30,
|
||||
});
|
||||
|
||||
// words: full new allowance (not in feature_ids — usage not carried)
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Words,
|
||||
balance: 300,
|
||||
usage: 0,
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,21 @@ export const AttachParamsV1Schema = BillingParamsBaseV1Schema.extend({
|
||||
.meta({
|
||||
description: "Whether to carry over balances from the previous plan.",
|
||||
}),
|
||||
|
||||
carry_over_usages: z
|
||||
.object({
|
||||
enabled: z.boolean().meta({
|
||||
description: "Whether to carry over usages from the previous plan.",
|
||||
}),
|
||||
feature_ids: z.array(z.string()).optional().meta({
|
||||
description:
|
||||
"The IDs of the features to carry over usages for. If left undefined, all consumable features will be carried over.",
|
||||
}),
|
||||
})
|
||||
.optional()
|
||||
.meta({
|
||||
description: "Whether to carry over usages from the previous plan.",
|
||||
}),
|
||||
});
|
||||
|
||||
export type AttachParamsV1 = z.infer<typeof AttachParamsV1Schema>;
|
||||
|
||||
Reference in New Issue
Block a user