chore: correct preview calculations
This commit is contained in:
@@ -564,7 +564,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: customized future phases
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ feature_id: TestFeature.Words, balance: 250 },
|
||||
{ feature_id: TestFeature.Dashboard, balance: null },
|
||||
{ feature_id: TestFeature.Dashboard, balance: 0 },
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -594,7 +594,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: customized future phases
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ feature_id: TestFeature.Words, balance: 250 },
|
||||
{ feature_id: TestFeature.Dashboard, balance: null },
|
||||
{ feature_id: TestFeature.Dashboard, balance: 0 },
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -692,7 +692,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: updating a future custom
|
||||
await ctx.db.query.customerProducts.findFirst({
|
||||
where: eq(customerProducts.id, initialFutureCustomerProductId),
|
||||
}),
|
||||
).toBeNull();
|
||||
).toBeUndefined();
|
||||
|
||||
const updatedFutureCustomerProduct =
|
||||
await ctx.db.query.customerProducts.findFirst({
|
||||
@@ -711,7 +711,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: updating a future custom
|
||||
ctx,
|
||||
customerProductId: updatedFutureCustomerProductId,
|
||||
}),
|
||||
).toEqual([20, 55]);
|
||||
).toEqual([55]);
|
||||
|
||||
await advanceTestClock({
|
||||
stripeCli: ctx.stripeCli,
|
||||
@@ -737,7 +737,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: updating a future custom
|
||||
ctx,
|
||||
customerProductId: updatedFutureCustomerProductId,
|
||||
}),
|
||||
).toEqual([20, 55]);
|
||||
).toEqual([55]);
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule: persists the new schedule and returns required_action when immediate billing is deferred")}`, async () => {
|
||||
@@ -836,8 +836,11 @@ test.concurrent(`${chalk.yellowBright("create-schedule: persists the new schedul
|
||||
),
|
||||
);
|
||||
|
||||
expect(phasesAfterDeferredAttempt).toHaveLength(1);
|
||||
expect(phasesAfterDeferredAttempt).toHaveLength(2);
|
||||
expect(phasesAfterDeferredAttempt[0]!.customer_product_ids).toEqual(
|
||||
initialResponse.phases[0]!.customer_product_ids,
|
||||
);
|
||||
expect(phasesAfterDeferredAttempt[1]!.customer_product_ids).toEqual(
|
||||
initialResponse.phases[1]!.customer_product_ids,
|
||||
);
|
||||
});
|
||||
@@ -1272,7 +1275,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: plans omitted from the n
|
||||
);
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule: updating a schedule after earlier phases started preserves history and edits the current phase")}`, async () => {
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule: rejects updating a schedule after earlier phases started when past phases are resubmitted")}`, async () => {
|
||||
const originalPastBase = products.base({
|
||||
id: "create-schedule-update-history-past-base",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
@@ -1330,79 +1333,27 @@ test.concurrent(`${chalk.yellowBright("create-schedule: updating a schedule afte
|
||||
waitForSeconds: 30,
|
||||
});
|
||||
|
||||
const updatedResponse = await autumnV1.billing.createSchedule({
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: now,
|
||||
plans: [{ plan_id: originalPastBase.id }],
|
||||
},
|
||||
{
|
||||
starts_at: now + ms.days(15),
|
||||
plans: [{ plan_id: currentBase.id }, { plan_id: currentAddon.id }],
|
||||
},
|
||||
{
|
||||
starts_at: now + ms.days(30),
|
||||
plans: [{ plan_id: futureBase.id }],
|
||||
},
|
||||
],
|
||||
await expectAutumnError({
|
||||
func: async () =>
|
||||
autumnV1.billing.createSchedule({
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: now,
|
||||
plans: [{ plan_id: originalPastBase.id }],
|
||||
},
|
||||
{
|
||||
starts_at: now + ms.days(15),
|
||||
plans: [{ plan_id: currentBase.id }, { plan_id: currentAddon.id }],
|
||||
},
|
||||
{
|
||||
starts_at: now + ms.days(30),
|
||||
plans: [{ plan_id: futureBase.id }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
errMessage: "The first phase must start immediately",
|
||||
});
|
||||
|
||||
expect(updatedResponse.phases.map((phase) => phase.starts_at)).toEqual([
|
||||
now,
|
||||
now + ms.days(15),
|
||||
now + ms.days(30),
|
||||
]);
|
||||
expect(updatedResponse.phases[0]!.customer_product_ids).toEqual(
|
||||
initialResponse.phases[0]!.customer_product_ids,
|
||||
);
|
||||
expect(updatedResponse.phases[1]!.customer_product_ids).toHaveLength(2);
|
||||
|
||||
const updatedSchedulePhases = await ctx.db
|
||||
.select({
|
||||
starts_at: schedulePhases.starts_at,
|
||||
customer_product_ids: schedulePhases.customer_product_ids,
|
||||
})
|
||||
.from(schedulePhases)
|
||||
.where(
|
||||
eq(
|
||||
schedulePhases.schedule_id,
|
||||
getRequiredScheduleId(updatedResponse.schedule_id),
|
||||
),
|
||||
);
|
||||
|
||||
expect(updatedSchedulePhases.map((phase) => phase.starts_at)).toEqual([
|
||||
now,
|
||||
now + ms.days(15),
|
||||
now + ms.days(30),
|
||||
]);
|
||||
expect(updatedSchedulePhases[0]!.customer_product_ids).toEqual(
|
||||
initialResponse.phases[0]!.customer_product_ids,
|
||||
);
|
||||
|
||||
const currentPhaseProducts = await ctx.db
|
||||
.select({
|
||||
productId: customerProducts.product_id,
|
||||
status: customerProducts.status,
|
||||
})
|
||||
.from(customerProducts)
|
||||
.where(
|
||||
inArray(
|
||||
customerProducts.id,
|
||||
updatedResponse.phases[1]!.customer_product_ids,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
currentPhaseProducts.sort((a, b) =>
|
||||
a.productId!.localeCompare(b.productId!),
|
||||
),
|
||||
).toEqual(
|
||||
[
|
||||
{ productId: currentAddon.id, status: CusProductStatus.Active },
|
||||
{ productId: currentBase.id, status: CusProductStatus.Active },
|
||||
].sort((a, b) => a.productId.localeCompare(b.productId)),
|
||||
);
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule: replacing a schedule removes old phases and leaves the correct replacement state in db")}`, async () => {
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
type AttachPreviewResponse,
|
||||
BillingInterval,
|
||||
BillingMethod,
|
||||
type CreateScheduleParamsV0Input,
|
||||
ms,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { addMonths } from "date-fns";
|
||||
|
||||
const previewCreateSchedule = async ({
|
||||
autumnV1,
|
||||
params,
|
||||
}: {
|
||||
autumnV1: Awaited<ReturnType<typeof initScenario>>["autumnV1"];
|
||||
params: CreateScheduleParamsV0Input;
|
||||
}): Promise<AttachPreviewResponse> =>
|
||||
await autumnV1.post("/billing.preview_create_schedule", params);
|
||||
|
||||
const sortNumbers = (values: number[]) => [...values].sort((a, b) => a - b);
|
||||
const sortStrings = (values: string[]) => [...values].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const expectPreviewToMatchCreateSchedule = async ({
|
||||
autumnV1,
|
||||
params,
|
||||
expectedTotal,
|
||||
expectedLineItemTotals,
|
||||
assertPreview,
|
||||
}: {
|
||||
autumnV1: Awaited<ReturnType<typeof initScenario>>["autumnV1"];
|
||||
params: CreateScheduleParamsV0Input;
|
||||
expectedTotal?: number;
|
||||
expectedLineItemTotals?: number[];
|
||||
assertPreview?: (preview: AttachPreviewResponse) => void;
|
||||
}) => {
|
||||
const preview = await previewCreateSchedule({ autumnV1, params });
|
||||
|
||||
if (expectedTotal !== undefined) {
|
||||
expect(preview.total).toBe(expectedTotal);
|
||||
expect(preview.subtotal).toBe(expectedTotal);
|
||||
}
|
||||
if (expectedLineItemTotals) {
|
||||
expect(
|
||||
sortNumbers(preview.line_items.map((lineItem) => lineItem.total)),
|
||||
).toEqual(sortNumbers(expectedLineItemTotals));
|
||||
}
|
||||
expect(
|
||||
preview.line_items.reduce((sum, lineItem) => sum + lineItem.total, 0),
|
||||
).toBe(preview.total);
|
||||
|
||||
assertPreview?.(preview);
|
||||
|
||||
const response = await autumnV1.billing.createSchedule(params);
|
||||
|
||||
expect(response.status).toBe("created");
|
||||
expect(response.invoice?.total ?? 0).toBe(preview.total);
|
||||
};
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule preview 1: immediate recurring plans match preview total")}`, async () => {
|
||||
const pro = products.pro({
|
||||
id: "preview-pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
const addon = products.recurringAddOn({
|
||||
id: "preview-addon",
|
||||
items: [items.monthlyWords({ includedUsage: 25 })],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, advancedTo } = await initScenario({
|
||||
customerId: "create-schedule-preview-recurring",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, addon] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expectPreviewToMatchCreateSchedule({
|
||||
autumnV1,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [{ plan_id: pro.id }, { plan_id: addon.id }],
|
||||
},
|
||||
{
|
||||
starts_at: advancedTo + ms.days(30),
|
||||
plans: [{ plan_id: pro.id }],
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedTotal: 40,
|
||||
expectedLineItemTotals: [20, 20],
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule preview 2: prepaid feature quantities bill immediately")}`, async () => {
|
||||
const prepaid = products.base({
|
||||
id: "preview-prepaid",
|
||||
items: [items.prepaidMessages()],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, advancedTo } = await initScenario({
|
||||
customerId: "create-schedule-preview-prepaid",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [prepaid] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expectPreviewToMatchCreateSchedule({
|
||||
autumnV1,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [
|
||||
{
|
||||
plan_id: prepaid.id,
|
||||
feature_quantities: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedTotal: 40,
|
||||
expectedLineItemTotals: [40],
|
||||
assertPreview: (preview) => {
|
||||
expect(preview.line_items).toContainEqual(
|
||||
expect.objectContaining({
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: 400,
|
||||
total: 40,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule preview 3: customize.price overrides the template base price")}`, async () => {
|
||||
const base = products.base({
|
||||
id: "preview-custom-price",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, advancedTo } = await initScenario({
|
||||
customerId: "create-schedule-preview-custom-price",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [base] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expectPreviewToMatchCreateSchedule({
|
||||
autumnV1,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [
|
||||
{
|
||||
plan_id: base.id,
|
||||
customize: {
|
||||
price: itemsV2.monthlyPrice({ amount: 35 }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedTotal: 35,
|
||||
expectedLineItemTotals: [35],
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule preview 4: graduated prepaid tiers use the correct total")}`, async () => {
|
||||
const tiered = products.base({
|
||||
id: "preview-tiered-prepaid",
|
||||
items: [items.tieredPrepaidMessages({ includedUsage: 0 })],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, advancedTo } = await initScenario({
|
||||
customerId: "create-schedule-preview-tiered-prepaid",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [tiered] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expectPreviewToMatchCreateSchedule({
|
||||
autumnV1,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [
|
||||
{
|
||||
plan_id: tiered.id,
|
||||
feature_quantities: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: 700,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedTotal: 60,
|
||||
expectedLineItemTotals: [60],
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule preview 5: volume prepaid tiers use the correct total")}`, async () => {
|
||||
const volume = products.base({
|
||||
id: "preview-volume-prepaid",
|
||||
items: [items.volumePrepaidMessages({ includedUsage: 0 })],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, advancedTo } = await initScenario({
|
||||
customerId: "create-schedule-preview-volume-prepaid",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [volume] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expectPreviewToMatchCreateSchedule({
|
||||
autumnV1,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [
|
||||
{
|
||||
plan_id: volume.id,
|
||||
feature_quantities: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: 700,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedTotal: 35,
|
||||
expectedLineItemTotals: [35],
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule preview 6: usage-based features stay out of the immediate total")}`, async () => {
|
||||
const usagePlan = products.pro({
|
||||
id: "preview-usage-plan",
|
||||
items: [items.consumableMessages({ includedUsage: 100, price: 0.5 })],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, advancedTo } = await initScenario({
|
||||
customerId: "create-schedule-preview-usage-based",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [usagePlan] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expectPreviewToMatchCreateSchedule({
|
||||
autumnV1,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [{ plan_id: usagePlan.id }],
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedTotal: 20,
|
||||
expectedLineItemTotals: [20],
|
||||
assertPreview: (preview) => {
|
||||
expect(
|
||||
preview.line_items.every((lineItem) => lineItem.feature_id === null),
|
||||
).toBe(true);
|
||||
expect(preview.next_cycle).toBeUndefined();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule preview 7: active upgrade preview matches the immediate replacement invoice")}`, async () => {
|
||||
const pro = products.pro({
|
||||
id: "preview-active-upgrade-pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "preview-active-upgrade-premium",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, advancedTo } = await initScenario({
|
||||
customerId: "create-schedule-preview-active-upgrade",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
await expectPreviewToMatchCreateSchedule({
|
||||
autumnV1,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [{ plan_id: premium.id }],
|
||||
},
|
||||
],
|
||||
},
|
||||
assertPreview: (preview) => {
|
||||
expect(preview.total).toBeGreaterThan(0);
|
||||
expect(preview.total).toBeLessThan(50);
|
||||
expect(preview.line_items.length).toBeGreaterThan(0);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule preview 8: active downgrade preview matches the immediate replacement invoice")}`, async () => {
|
||||
const pro = products.pro({
|
||||
id: "preview-active-downgrade-pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "preview-active-downgrade-premium",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, advancedTo } = await initScenario({
|
||||
customerId: "create-schedule-preview-active-downgrade",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: premium.id })],
|
||||
});
|
||||
|
||||
await expectPreviewToMatchCreateSchedule({
|
||||
autumnV1,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [{ plan_id: pro.id }],
|
||||
},
|
||||
],
|
||||
},
|
||||
assertPreview: (preview) => {
|
||||
expect(preview.total).toBeLessThan(20);
|
||||
expect(preview.line_items.length).toBeGreaterThan(0);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule preview 9: mixed immediate phase only charges recurring and prepaid items")}`, async () => {
|
||||
const recurring = products.pro({
|
||||
id: "preview-mixed-recurring",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
group: "preview-mixed-recurring",
|
||||
});
|
||||
const prepaid = products.base({
|
||||
id: "preview-mixed-prepaid",
|
||||
items: [items.prepaidUsers()],
|
||||
group: "preview-mixed-prepaid",
|
||||
});
|
||||
const usageBased = products.base({
|
||||
id: "preview-mixed-usage",
|
||||
items: [items.consumableWords({ includedUsage: 100 })],
|
||||
group: "preview-mixed-usage",
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, advancedTo } = await initScenario({
|
||||
customerId: "create-schedule-preview-mixed",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [recurring, prepaid, usageBased] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expectPreviewToMatchCreateSchedule({
|
||||
autumnV1,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [
|
||||
{ plan_id: recurring.id },
|
||||
{
|
||||
plan_id: prepaid.id,
|
||||
feature_quantities: [
|
||||
{
|
||||
feature_id: TestFeature.Users,
|
||||
quantity: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ plan_id: usageBased.id },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedTotal: 60,
|
||||
expectedLineItemTotals: [20, 40],
|
||||
assertPreview: (preview) => {
|
||||
expect(
|
||||
preview.line_items.some(
|
||||
(lineItem) => lineItem.feature_id === TestFeature.Words,
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule preview 10: customize.items uses custom prepaid and one-off prices")}`, async () => {
|
||||
const base = products.base({
|
||||
id: "preview-custom-items-chargeable",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, advancedTo } = await initScenario({
|
||||
customerId: "create-schedule-preview-custom-items-chargeable",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [base] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expectPreviewToMatchCreateSchedule({
|
||||
autumnV1,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [
|
||||
{
|
||||
plan_id: base.id,
|
||||
feature_quantities: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: 300,
|
||||
},
|
||||
{
|
||||
feature_id: TestFeature.Words,
|
||||
quantity: 200,
|
||||
},
|
||||
],
|
||||
customize: {
|
||||
items: [
|
||||
itemsV2.prepaidMessages({
|
||||
amount: 12,
|
||||
billingUnits: 100,
|
||||
}),
|
||||
{
|
||||
feature_id: TestFeature.Words,
|
||||
included: 0,
|
||||
price: {
|
||||
amount: 15,
|
||||
interval: BillingInterval.OneOff,
|
||||
billing_method: BillingMethod.Prepaid,
|
||||
billing_units: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature_id: TestFeature.Users,
|
||||
included: 0,
|
||||
price: {
|
||||
amount: 7,
|
||||
interval: BillingInterval.Month,
|
||||
billing_method: BillingMethod.UsageBased,
|
||||
billing_units: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedTotal: 66,
|
||||
expectedLineItemTotals: [0, 30, 36],
|
||||
assertPreview: (preview) => {
|
||||
expect(
|
||||
sortStrings(
|
||||
preview.line_items.map((lineItem) => lineItem.feature_id ?? "base"),
|
||||
),
|
||||
).toEqual(
|
||||
sortStrings([
|
||||
TestFeature.Messages,
|
||||
TestFeature.Users,
|
||||
TestFeature.Words,
|
||||
]),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule preview 11: one-off plan charges now and has no next cycle")}`, async () => {
|
||||
const oneOff = products.base({
|
||||
id: "preview-one-off-base",
|
||||
items: [items.oneOffPrice({ price: 50 }), items.monthlyMessages()],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, advancedTo } = await initScenario({
|
||||
customerId: "create-schedule-preview-one-off",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [oneOff] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expectPreviewToMatchCreateSchedule({
|
||||
autumnV1,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [{ plan_id: oneOff.id }],
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedTotal: 50,
|
||||
expectedLineItemTotals: [50],
|
||||
assertPreview: (preview) => {
|
||||
expect(preview.next_cycle).toBeUndefined();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule preview 12: prepaid quantities only charge for units above included usage")}`, async () => {
|
||||
const prepaid = products.base({
|
||||
id: "preview-prepaid-included-usage",
|
||||
items: [items.prepaidMessages({ includedUsage: 200 })],
|
||||
group: "preview-prepaid-included-usage",
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, advancedTo } = await initScenario({
|
||||
customerId: "create-schedule-preview-prepaid-included-usage",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [prepaid] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expectPreviewToMatchCreateSchedule({
|
||||
autumnV1,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [
|
||||
{
|
||||
plan_id: prepaid.id,
|
||||
feature_quantities: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedTotal: 0,
|
||||
assertPreview: (preview) => {
|
||||
expect(
|
||||
preview.line_items.every((lineItem) => lineItem.total === 0),
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create-schedule preview 13: active schedules can defer a future replacement without charging now")}`, async () => {
|
||||
const pro = products.pro({
|
||||
id: "preview-future-replacement-pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "preview-future-replacement-premium",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, advancedTo } = await initScenario({
|
||||
customerId: "create-schedule-preview-future-replacement",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
await expectPreviewToMatchCreateSchedule({
|
||||
autumnV1,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
phases: [
|
||||
{
|
||||
starts_at: advancedTo,
|
||||
plans: [{ plan_id: pro.id }],
|
||||
},
|
||||
{
|
||||
starts_at: advancedTo + ms.days(15),
|
||||
plans: [{ plan_id: premium.id }],
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedTotal: 0,
|
||||
assertPreview: (preview) => {
|
||||
expect(preview.line_items).toHaveLength(0);
|
||||
expect(preview.next_cycle).toBeDefined();
|
||||
expect(preview.next_cycle?.total).toBe(50);
|
||||
expect(preview.next_cycle?.starts_at).toBeCloseTo(
|
||||
addMonths(advancedTo, 1).getTime(),
|
||||
-ms.days(1),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatAmount, type ProductItem, type ProductV2 } from "@autumn/shared";
|
||||
import type { ProductItem, ProductV2 } from "@autumn/shared";
|
||||
import {
|
||||
PackageIcon,
|
||||
PencilSimpleIcon,
|
||||
@@ -7,55 +7,23 @@ import {
|
||||
} from "@phosphor-icons/react";
|
||||
import { Badge } from "@/components/v2/badges/Badge";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { PriceDisplay } from "@/components/forms/update-subscription-v2/components/PriceDisplay";
|
||||
import { SearchableSelect } from "@/components/v2/selects/SearchableSelect";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCreateScheduleFormContext } from "../context/CreateScheduleFormProvider";
|
||||
|
||||
export function getItemUnitPrice(item: ProductItem): number | null {
|
||||
if (item.price != null) return item.price;
|
||||
if (item.tiers?.length === 1) return item.tiers[0].amount ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getPlanPriceLabel({
|
||||
export function getSchedulePlanPriceProduct({
|
||||
product,
|
||||
customItems,
|
||||
prepaidOptions,
|
||||
}: {
|
||||
product: ProductV2;
|
||||
customItems?: ProductItem[] | null;
|
||||
prepaidOptions?: Record<string, number>;
|
||||
}): string | null {
|
||||
const items = customItems ?? product.items;
|
||||
const pricedItems =
|
||||
items?.filter((item) => getItemUnitPrice(item) != null && item.interval) ??
|
||||
[];
|
||||
|
||||
if (pricedItems.length === 0) return "Free";
|
||||
|
||||
const totalAmount = pricedItems.reduce((sum, item) => {
|
||||
const unitPrice = getItemUnitPrice(item) ?? 0;
|
||||
const quantity =
|
||||
item.feature_id && prepaidOptions?.[item.feature_id]
|
||||
? prepaidOptions[item.feature_id]
|
||||
: 1;
|
||||
return sum + unitPrice * quantity;
|
||||
}, 0);
|
||||
const firstItem = pricedItems[0]!;
|
||||
const intervalCount = firstItem.interval_count ?? 1;
|
||||
const intervalLabel =
|
||||
intervalCount === 1
|
||||
? `/${firstItem.interval}`
|
||||
: `/${intervalCount} ${firstItem.interval}s`;
|
||||
|
||||
return `${formatAmount({
|
||||
currency: "USD",
|
||||
amount: totalAmount,
|
||||
amountFormatOptions: {
|
||||
style: "currency",
|
||||
currencyDisplay: "narrowSymbol",
|
||||
},
|
||||
})}${intervalLabel}`;
|
||||
}): ProductV2 {
|
||||
return {
|
||||
...product,
|
||||
items: customItems ?? product.items,
|
||||
};
|
||||
}
|
||||
|
||||
export function SchedulePlanRow({
|
||||
@@ -75,6 +43,7 @@ export function SchedulePlanRow({
|
||||
isPhaseLocked,
|
||||
setEditingPlan,
|
||||
} = useCreateScheduleFormContext();
|
||||
const { org } = useOrg();
|
||||
|
||||
const plan = formValues.phases[phaseIndex]?.plans[planIndex];
|
||||
if (!plan) return null;
|
||||
@@ -83,11 +52,10 @@ export function SchedulePlanRow({
|
||||
const availableProducts = products.filter((p) => !p.archived);
|
||||
const selectedProduct = products.find((p) => p.id === plan.productId);
|
||||
const hasCustomizations = plan.isCustom;
|
||||
const priceLabel = selectedProduct
|
||||
? getPlanPriceLabel({
|
||||
const priceProduct = selectedProduct
|
||||
? getSchedulePlanPriceProduct({
|
||||
product: selectedProduct,
|
||||
customItems: plan.items,
|
||||
prepaidOptions: plan.prepaidOptions,
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -184,8 +152,13 @@ export function SchedulePlanRow({
|
||||
Custom
|
||||
</Badge>
|
||||
)}
|
||||
{priceLabel && (
|
||||
<span className="text-xs text-t3 tabular-nums">{priceLabel}</span>
|
||||
{priceProduct && (
|
||||
<span className="text-xs text-t3 tabular-nums">
|
||||
<PriceDisplay
|
||||
product={priceProduct}
|
||||
currency={org?.default_currency ?? "USD"}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -10,11 +10,18 @@ interface PriceDisplayProps {
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export function PriceDisplay({ product, currency }: PriceDisplayProps) {
|
||||
type ProductPriceDisplay =
|
||||
| { type: "free" }
|
||||
| { type: "price"; formattedPrice: string; intervalText: string };
|
||||
|
||||
export function getProductPriceDisplay({
|
||||
product,
|
||||
currency,
|
||||
}: PriceDisplayProps): ProductPriceDisplay {
|
||||
const priceItem = product?.items?.find((i) => isPriceItem(i));
|
||||
|
||||
if (!priceItem || priceItem.price === 0 || priceItem.price === undefined) {
|
||||
return <span className="text-t2">Free</span>;
|
||||
return { type: "free" };
|
||||
}
|
||||
|
||||
const formattedPrice = formatAmount({
|
||||
@@ -33,10 +40,20 @@ export function PriceDisplay({ product, currency }: PriceDisplayProps) {
|
||||
})
|
||||
: "one-off";
|
||||
|
||||
return { type: "price", formattedPrice, intervalText };
|
||||
}
|
||||
|
||||
export function PriceDisplay({ product, currency }: PriceDisplayProps) {
|
||||
const priceDisplay = getProductPriceDisplay({ product, currency });
|
||||
|
||||
if (priceDisplay.type === "free") {
|
||||
return <span className="text-t2">Free</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-t1 font-semibold">{formattedPrice}</span>
|
||||
<span className="text-t3">{intervalText}</span>
|
||||
<span className="text-t1 font-semibold">{priceDisplay.formattedPrice}</span>
|
||||
<span className="text-t3">{priceDisplay.intervalText}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { AppEnv, type ProductItem, type ProductV2 } from "@autumn/shared";
|
||||
import {
|
||||
getItemUnitPrice,
|
||||
getPlanPriceLabel,
|
||||
} from "@/components/forms/create-schedule/components/SchedulePlanRow";
|
||||
import { getSchedulePlanPriceProduct } from "@/components/forms/create-schedule/components/SchedulePlanRow";
|
||||
import { getProductPriceDisplay } from "@/components/forms/update-subscription-v2/components/PriceDisplay";
|
||||
|
||||
function makeProduct({
|
||||
id = "prod_1",
|
||||
@@ -66,29 +64,25 @@ const freeFeatureItem: ProductItem = {
|
||||
interval: null,
|
||||
} as ProductItem;
|
||||
|
||||
describe("getItemUnitPrice", () => {
|
||||
test("returns price for base price item", () => {
|
||||
expect(getItemUnitPrice(basePriceItem)).toBe(15);
|
||||
});
|
||||
|
||||
test("returns price for feature item with direct price", () => {
|
||||
expect(getItemUnitPrice(featurePriceViaPrice)).toBe(10);
|
||||
});
|
||||
|
||||
test("returns tiers[0].amount for single-tier feature item", () => {
|
||||
expect(getItemUnitPrice(featurePriceViaTiers)).toBe(10);
|
||||
});
|
||||
|
||||
test("returns null for multi-tier feature item", () => {
|
||||
expect(getItemUnitPrice(multiTierItem)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for free feature item", () => {
|
||||
expect(getItemUnitPrice(freeFeatureItem)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlanPriceLabel", () => {
|
||||
const getPlanPriceLabel = ({
|
||||
product,
|
||||
customItems,
|
||||
}: {
|
||||
product: ProductV2;
|
||||
customItems?: ProductItem[] | null;
|
||||
}) => {
|
||||
const priceProduct = getSchedulePlanPriceProduct({ product, customItems });
|
||||
const priceDisplay = getProductPriceDisplay({
|
||||
product: priceProduct,
|
||||
currency: "USD",
|
||||
});
|
||||
|
||||
return priceDisplay.type === "free"
|
||||
? "Free"
|
||||
: `${priceDisplay.formattedPrice} ${priceDisplay.intervalText}`;
|
||||
};
|
||||
|
||||
test("returns Free when no priced items", () => {
|
||||
const product = makeProduct({ items: [freeFeatureItem] });
|
||||
expect(getPlanPriceLabel({ product })).toBe("Free");
|
||||
@@ -98,39 +92,24 @@ describe("getPlanPriceLabel", () => {
|
||||
const product = makeProduct({ items: [basePriceItem] });
|
||||
const label = getPlanPriceLabel({ product });
|
||||
expect(label).toContain("15");
|
||||
expect(label).toContain("/month");
|
||||
expect(label).toContain("per month");
|
||||
});
|
||||
|
||||
test("sums base price + feature price with prepaid quantity", () => {
|
||||
const product = makeProduct({
|
||||
items: [basePriceItem, featurePriceViaTiers],
|
||||
});
|
||||
const label = getPlanPriceLabel({
|
||||
product,
|
||||
prepaidOptions: { users: 5 },
|
||||
});
|
||||
expect(label).toContain("65");
|
||||
expect(label).toContain("/month");
|
||||
});
|
||||
|
||||
test("uses single-tier amount for feature items with tiers", () => {
|
||||
const product = makeProduct({ items: [featurePriceViaTiers] });
|
||||
const label = getPlanPriceLabel({
|
||||
product,
|
||||
prepaidOptions: { users: 3 },
|
||||
});
|
||||
expect(label).toContain("30");
|
||||
});
|
||||
|
||||
test("multiplies by 1 when feature has no prepaid option", () => {
|
||||
test("uses base price display when plan also has priced features", () => {
|
||||
const product = makeProduct({
|
||||
items: [basePriceItem, featurePriceViaTiers],
|
||||
});
|
||||
const label = getPlanPriceLabel({ product });
|
||||
expect(label).toContain("25");
|
||||
expect(label).toContain("15");
|
||||
expect(label).toContain("per month");
|
||||
});
|
||||
|
||||
test("skips multi-tier items (cannot compute fixed total)", () => {
|
||||
test("returns Free when plan only has priced feature items", () => {
|
||||
const product = makeProduct({ items: [featurePriceViaTiers] });
|
||||
expect(getPlanPriceLabel({ product })).toBe("Free");
|
||||
});
|
||||
|
||||
test("uses base price even when priced feature is multi-tier", () => {
|
||||
const product = makeProduct({ items: [basePriceItem, multiTierItem] });
|
||||
const label = getPlanPriceLabel({ product });
|
||||
expect(label).toContain("15");
|
||||
@@ -142,18 +121,13 @@ describe("getPlanPriceLabel", () => {
|
||||
{ ...basePriceItem, price: 20 } as ProductItem,
|
||||
featurePriceViaTiers,
|
||||
];
|
||||
const label = getPlanPriceLabel({
|
||||
product,
|
||||
customItems,
|
||||
prepaidOptions: { users: 5 },
|
||||
});
|
||||
expect(label).toContain("70");
|
||||
const label = getPlanPriceLabel({ product, customItems });
|
||||
expect(label).toContain("20");
|
||||
expect(label).toContain("per month");
|
||||
});
|
||||
|
||||
test("returns Free when customItems has no priced items", () => {
|
||||
test("returns Free when customItems only has priced features", () => {
|
||||
const product = makeProduct({ items: [basePriceItem] });
|
||||
expect(
|
||||
getPlanPriceLabel({ product, customItems: [freeFeatureItem] }),
|
||||
).toBe("Free");
|
||||
expect(getPlanPriceLabel({ product, customItems: [featurePriceViaPrice] })).toBe("Free");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user