1103 lines
31 KiB
TypeScript
1103 lines
31 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { type ApiCustomerV3, applyProration } from "@autumn/shared";
|
|
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
|
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
|
import {
|
|
expectSubCount,
|
|
expectSubToBeCorrect,
|
|
} from "@tests/merged/mergeUtils/expectSubCorrect";
|
|
import { TestFeature } from "@tests/setup/v2Features";
|
|
import { items } from "@tests/utils/fixtures/items";
|
|
import { products } from "@tests/utils/fixtures/products";
|
|
import { advanceTestClock } from "@tests/utils/stripeUtils";
|
|
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
|
import chalk from "chalk";
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// PAID-TO-PAID: BASE PRICE CHANGES
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
// 3.1 Increase base price ($20 -> $30)
|
|
test.concurrent(`${chalk.yellowBright("p2p: increase base price")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const priceItem = items.monthlyPrice({ price: 20 });
|
|
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "p2p-inc-price",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage before update
|
|
const messagesUsage = 40;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Increase price from $20 to $30
|
|
const newPriceItem = items.monthlyPrice({ price: 30 });
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [messagesItem, newPriceItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Should charge $10 difference ($30 - $20)
|
|
expect(preview.total).toBe(10);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Usage should stay the same
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: messagesItem.included_usage,
|
|
balance: messagesItem.included_usage - messagesUsage,
|
|
usage: messagesUsage,
|
|
});
|
|
|
|
// Verify invoice matches preview
|
|
await expectCustomerInvoiceCorrect({
|
|
customer,
|
|
count: 2, // Initial attach + upgrade
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// 3.2 Decrease base price ($30 -> $20)
|
|
test.concurrent(`${chalk.yellowBright("p2p: decrease base price")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const priceItem = items.monthlyPrice({ price: 30 });
|
|
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "p2p-dec-price",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage before update
|
|
const messagesUsage = 25;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Decrease price from $30 to $20
|
|
const newPriceItem = items.monthlyPrice({ price: 20 });
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [messagesItem, newPriceItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Should credit $10 difference ($20 - $30)
|
|
expect(preview.total).toBe(-10);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Usage should stay the same
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: messagesItem.included_usage,
|
|
balance: messagesItem.included_usage - messagesUsage,
|
|
usage: messagesUsage,
|
|
});
|
|
|
|
// Verify invoice matches preview
|
|
await expectCustomerInvoiceCorrect({
|
|
customer,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// 3.3 Remove base price (paid to free)
|
|
test.concurrent(`${chalk.yellowBright("p2p: remove base price (to free)")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const priceItem = items.monthlyPrice({ price: 20 });
|
|
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "p2p-remove-price",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage
|
|
const messagesUsage = 50;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Remove price, keep only messages
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [messagesItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Should credit full $20 (refund for unused portion)
|
|
expect(preview.total).toBe(-20);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Usage should stay the same
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: messagesItem.included_usage,
|
|
balance: messagesItem.included_usage - messagesUsage,
|
|
usage: messagesUsage,
|
|
});
|
|
|
|
// Now free, should have no Stripe subscription
|
|
await expectSubCount({
|
|
ctx,
|
|
customerId,
|
|
count: 0,
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// PAID-TO-PAID: BOOLEAN FEATURE ADD/REMOVE
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
// 1.1 Add boolean feature to paid plan
|
|
test.concurrent(`${chalk.yellowBright("p2p: add boolean feature")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const priceItem = items.monthlyPrice({ price: 20 });
|
|
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "p2p-add-bool",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage before update
|
|
const messagesUsage = 35;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Get original reset time
|
|
const customerBefore =
|
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
const originalResetAt =
|
|
customerBefore.features[TestFeature.Messages].next_reset_at;
|
|
expect(originalResetAt).toBeDefined();
|
|
|
|
// Add boolean dashboard feature
|
|
const dashboardItem = items.dashboard();
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [messagesItem, priceItem, dashboardItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Boolean features have no price impact
|
|
expect(preview.total).toBe(0);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Messages usage should stay the same
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: messagesItem.included_usage,
|
|
balance: messagesItem.included_usage - messagesUsage,
|
|
usage: messagesUsage,
|
|
resetsAt: originalResetAt!,
|
|
});
|
|
|
|
// Dashboard should be accessible (boolean feature)
|
|
expect(customer.features[TestFeature.Dashboard]).toBeDefined();
|
|
|
|
// Verify invoice matches preview
|
|
await expectCustomerInvoiceCorrect({
|
|
customer,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// 1.2 Remove boolean feature from paid plan
|
|
test.concurrent(`${chalk.yellowBright("p2p: remove boolean feature")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const priceItem = items.monthlyPrice({ price: 20 });
|
|
const dashboardItem = items.dashboard();
|
|
const pro = products.base({
|
|
id: "pro",
|
|
items: [messagesItem, priceItem, dashboardItem],
|
|
});
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "p2p-remove-bool",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage before update
|
|
const messagesUsage = 60;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Get original reset time
|
|
const customerBefore =
|
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
const originalResetAt =
|
|
customerBefore.features[TestFeature.Messages].next_reset_at;
|
|
|
|
// Verify dashboard is accessible before
|
|
expect(customerBefore.features[TestFeature.Dashboard]).toBeDefined();
|
|
|
|
// Remove dashboard feature
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [messagesItem, priceItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Boolean features have no price impact
|
|
expect(preview.total).toBe(0);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Messages usage should stay the same
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: messagesItem.included_usage,
|
|
balance: messagesItem.included_usage - messagesUsage,
|
|
usage: messagesUsage,
|
|
resetsAt: originalResetAt!,
|
|
});
|
|
|
|
// Dashboard should no longer be accessible
|
|
expect(customer.features[TestFeature.Dashboard]).toBeUndefined();
|
|
|
|
// Verify invoice matches preview
|
|
await expectCustomerInvoiceCorrect({
|
|
customer,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// 1.3 Add second metered feature
|
|
test.concurrent(`${chalk.yellowBright("p2p: add second metered feature")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const priceItem = items.monthlyPrice({ price: 20 });
|
|
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "p2p-add-metered",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage before update
|
|
const messagesUsage = 45;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Add words feature
|
|
const wordsItem = items.monthlyWords({ includedUsage: 200 });
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [messagesItem, priceItem, wordsItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Adding included feature has no price impact
|
|
expect(preview.total).toBe(0);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Messages usage should stay the same
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: messagesItem.included_usage,
|
|
balance: messagesItem.included_usage - messagesUsage,
|
|
usage: messagesUsage,
|
|
});
|
|
|
|
// Words should have full balance
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Words,
|
|
includedUsage: wordsItem.included_usage,
|
|
balance: wordsItem.included_usage,
|
|
usage: 0,
|
|
});
|
|
|
|
// Verify invoice matches preview
|
|
await expectCustomerInvoiceCorrect({
|
|
customer,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// 1.4 Remove one metered feature
|
|
test.concurrent(`${chalk.yellowBright("p2p: remove one metered feature")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const wordsItem = items.monthlyWords({ includedUsage: 200 });
|
|
const priceItem = items.monthlyPrice({ price: 20 });
|
|
const pro = products.base({
|
|
id: "pro",
|
|
items: [messagesItem, wordsItem, priceItem],
|
|
});
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "p2p-remove-metered",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track usage on both features
|
|
const messagesUsage = 30;
|
|
const wordsUsage = 80;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Words,
|
|
value: wordsUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
return;
|
|
|
|
// Remove words feature
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [messagesItem, priceItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Removing included feature has no price impact
|
|
expect(preview.total).toBe(0);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Messages usage should stay the same
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: messagesItem.included_usage,
|
|
balance: messagesItem.included_usage - messagesUsage,
|
|
usage: messagesUsage,
|
|
});
|
|
|
|
// Words should no longer exist
|
|
expect(customer.features[TestFeature.Words]).toBeUndefined();
|
|
|
|
// Verify invoice matches preview
|
|
await expectCustomerInvoiceCorrect({
|
|
customer,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// PAID-TO-PAID: INCLUDED USAGE CHANGES
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
// 2.1 Increase included usage (100 -> 200)
|
|
test.concurrent(`${chalk.yellowBright("p2p: increase included usage")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const priceItem = items.monthlyPrice({ price: 20 });
|
|
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "p2p-inc-usage",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage
|
|
const messagesUsage = 70;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Increase included usage from 100 to 200
|
|
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [updatedMessagesItem, priceItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Included usage change has no price impact
|
|
expect(preview.total).toBe(0);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Usage should stay, balance should increase
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: updatedMessagesItem.included_usage,
|
|
balance: updatedMessagesItem.included_usage - messagesUsage,
|
|
usage: messagesUsage,
|
|
});
|
|
|
|
// Verify invoice matches preview
|
|
await expectCustomerInvoiceCorrect({
|
|
customer,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// 2.2 Decrease included usage (200 -> 100)
|
|
test.concurrent(`${chalk.yellowBright("p2p: decrease included usage")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 200 });
|
|
const priceItem = items.monthlyPrice({ price: 20 });
|
|
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "p2p-dec-usage",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage
|
|
const messagesUsage = 80;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Decrease included usage from 200 to 100
|
|
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [updatedMessagesItem, priceItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Included usage change has no price impact
|
|
expect(preview.total).toBe(0);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Usage should stay, balance should decrease
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: updatedMessagesItem.included_usage,
|
|
balance: updatedMessagesItem.included_usage - messagesUsage,
|
|
usage: messagesUsage,
|
|
});
|
|
|
|
// Verify invoice matches preview
|
|
await expectCustomerInvoiceCorrect({
|
|
customer,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// 2.3 Change to unlimited
|
|
test.concurrent(`${chalk.yellowBright("p2p: change to unlimited")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const priceItem = items.monthlyPrice({ price: 20 });
|
|
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "p2p-to-unlimited",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage
|
|
const messagesUsage = 90;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Change to unlimited
|
|
const unlimitedMessagesItem = items.unlimitedMessages();
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [unlimitedMessagesItem, priceItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Changing to unlimited has no price impact
|
|
expect(preview.total).toBe(0);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Messages should now be unlimited
|
|
expect(customer.features[TestFeature.Messages].unlimited).toBe(true);
|
|
|
|
// Verify invoice matches preview
|
|
await expectCustomerInvoiceCorrect({
|
|
customer,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// PAID-TO-PAID: TIME-ADVANCED PRICE CHANGES (TEST CLOCK)
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
// 7.1 Mid-cycle (15 days) price increase
|
|
test.concurrent(`${chalk.yellowBright("p2p: mid-cycle price increase")}`, async () => {
|
|
const oldPrice = 20;
|
|
const newPrice = 30;
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const priceItem = items.monthlyPrice({ price: oldPrice });
|
|
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
|
|
|
|
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
|
|
customerId: "p2p-midcycle-inc",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage
|
|
const messagesUsage = 55;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Advance 15 days (mid-cycle)
|
|
const advancedTo = await advanceTestClock({
|
|
stripeCli: ctx.stripeCli,
|
|
testClockId: testClockId!,
|
|
numberOfDays: 15,
|
|
});
|
|
|
|
// Use floored seconds to match Stripe's frozen_time calculation
|
|
const frozenTimeMs = Math.floor(advancedTo / 1000) * 1000;
|
|
|
|
// Get billing period from customer's subscription
|
|
const customerBefore =
|
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
const subscription = customerBefore.products?.[0];
|
|
if (!subscription?.current_period_start || !subscription?.current_period_end)
|
|
throw new Error("Missing billing period on subscription");
|
|
|
|
const billingPeriod = {
|
|
start: subscription.current_period_start,
|
|
end: subscription.current_period_end,
|
|
};
|
|
|
|
// Increase price from $20 to $30
|
|
const newPriceItem = items.monthlyPrice({ price: newPrice });
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [messagesItem, newPriceItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Calculate exact proration: credit old price + charge new price
|
|
const proratedOldPrice = applyProration({
|
|
now: frozenTimeMs,
|
|
billingPeriod,
|
|
amount: oldPrice,
|
|
});
|
|
const proratedNewPrice = applyProration({
|
|
now: frozenTimeMs,
|
|
billingPeriod,
|
|
amount: newPrice,
|
|
});
|
|
const expectedAmount = proratedNewPrice - proratedOldPrice;
|
|
|
|
expect(preview.total).toBeCloseTo(expectedAmount, 0);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: messagesItem.included_usage,
|
|
balance: messagesItem.included_usage - messagesUsage,
|
|
usage: messagesUsage,
|
|
});
|
|
|
|
// Verify invoice matches preview
|
|
await expectCustomerInvoiceCorrect({
|
|
customer,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// 7.2 Mid-cycle (15 days) price decrease
|
|
test.concurrent(`${chalk.yellowBright("p2p: mid-cycle price decrease")}`, async () => {
|
|
const oldPrice = 30;
|
|
const newPrice = 20;
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const priceItem = items.monthlyPrice({ price: oldPrice });
|
|
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
|
|
|
|
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
|
|
customerId: "p2p-midcycle-dec",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage
|
|
const messagesUsage = 45;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Advance 15 days (mid-cycle)
|
|
const advancedTo = await advanceTestClock({
|
|
stripeCli: ctx.stripeCli,
|
|
testClockId: testClockId!,
|
|
numberOfDays: 15,
|
|
});
|
|
|
|
// Use floored seconds to match Stripe's frozen_time calculation
|
|
const frozenTimeMs = Math.floor(advancedTo / 1000) * 1000;
|
|
|
|
// Get billing period from customer's subscription
|
|
const customerBefore =
|
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
const subscription = customerBefore.products?.[0];
|
|
if (!subscription?.current_period_start || !subscription?.current_period_end)
|
|
throw new Error("Missing billing period on subscription");
|
|
|
|
const billingPeriod = {
|
|
start: subscription.current_period_start,
|
|
end: subscription.current_period_end,
|
|
};
|
|
|
|
// Decrease price from $30 to $20
|
|
const newPriceItem = items.monthlyPrice({ price: newPrice });
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [messagesItem, newPriceItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Calculate exact proration: credit old price + charge new price
|
|
const proratedOldPrice = applyProration({
|
|
now: frozenTimeMs,
|
|
billingPeriod,
|
|
amount: oldPrice,
|
|
});
|
|
const proratedNewPrice = applyProration({
|
|
now: frozenTimeMs,
|
|
billingPeriod,
|
|
amount: newPrice,
|
|
});
|
|
const expectedAmount = proratedNewPrice - proratedOldPrice;
|
|
|
|
expect(preview.total).toBeCloseTo(expectedAmount, 0);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: messagesItem.included_usage,
|
|
balance: messagesItem.included_usage - messagesUsage,
|
|
usage: messagesUsage,
|
|
});
|
|
|
|
// Verify invoice matches preview
|
|
await expectCustomerInvoiceCorrect({
|
|
customer,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// PAID-TO-PAID: COMBINATION PRICE + FEATURE UPDATES
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
// 9.1 Increase price + add feature
|
|
test.concurrent(`${chalk.yellowBright("p2p: increase price + add feature")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const priceItem = items.monthlyPrice({ price: 20 });
|
|
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "p2p-combo-inc-add",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage
|
|
const messagesUsage = 60;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Increase price AND add dashboard
|
|
const newPriceItem = items.monthlyPrice({ price: 30 });
|
|
const dashboardItem = items.dashboard();
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [messagesItem, newPriceItem, dashboardItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Should charge $10 ($30 - $20), dashboard is free boolean
|
|
expect(preview.total).toBe(10);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Messages usage preserved
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: messagesItem.included_usage,
|
|
balance: messagesItem.included_usage - messagesUsage,
|
|
usage: messagesUsage,
|
|
});
|
|
|
|
// Dashboard added
|
|
expect(customer.features[TestFeature.Dashboard]).toBeDefined();
|
|
|
|
// Verify invoice matches preview
|
|
await expectCustomerInvoiceCorrect({
|
|
customer,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// 9.2 Decrease price + remove feature
|
|
test.concurrent(`${chalk.yellowBright("p2p: decrease price + remove feature")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const priceItem = items.monthlyPrice({ price: 30 });
|
|
const dashboardItem = items.dashboard();
|
|
const pro = products.base({
|
|
id: "pro",
|
|
items: [messagesItem, priceItem, dashboardItem],
|
|
});
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "p2p-combo-dec-remove",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage
|
|
const messagesUsage = 40;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Decrease price AND remove dashboard
|
|
const newPriceItem = items.monthlyPrice({ price: 20 });
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [messagesItem, newPriceItem],
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Should credit $10 ($20 - $30), dashboard is free boolean
|
|
expect(preview.total).toBe(-10);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Messages usage preserved
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: messagesItem.included_usage,
|
|
balance: messagesItem.included_usage - messagesUsage,
|
|
usage: messagesUsage,
|
|
});
|
|
|
|
// Dashboard removed
|
|
expect(customer.features[TestFeature.Dashboard]).toBeUndefined();
|
|
|
|
// Verify invoice matches preview
|
|
await expectCustomerInvoiceCorrect({
|
|
customer,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|