316 lines
9.2 KiB
TypeScript
316 lines
9.2 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import type { ApiCusProductV3, ApiCustomerV3 } from "@shared/index";
|
|
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
|
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
|
import { 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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
|
import chalk from "chalk";
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// VERSION PRICING: Price changes between versions
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
// 1.1 Price increase: v1 $20 → v2 $30
|
|
test.concurrent(`${chalk.yellowBright("version-pricing: price increase")}`, 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: "version-pricing-inc",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Create v2 with higher price ($30)
|
|
const newPriceItem = items.monthlyPrice({ price: 30 });
|
|
await autumnV1.products.update(pro.id, {
|
|
items: [messagesItem, newPriceItem],
|
|
});
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
version: 2,
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Should charge $10 difference ($30 - $20)
|
|
expect(preview.total).toBe(10);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
await expectCustomerInvoiceCorrect({
|
|
customerId,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// 1.2 Price decrease: v1 $30 → v2 $20
|
|
test.concurrent(`${chalk.yellowBright("version-pricing: price decrease")}`, 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: "version-pricing-dec",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Create v2 with lower price ($20)
|
|
const newPriceItem = items.monthlyPrice({ price: 20 });
|
|
await autumnV1.products.update(pro.id, {
|
|
items: [messagesItem, newPriceItem],
|
|
});
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
version: 2,
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Should credit $10 difference ($20 - $30)
|
|
expect(preview.total).toBe(-10);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
await expectCustomerInvoiceCorrect({
|
|
customerId,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// 1.3 Price unchanged: v1 $20 → v2 $20 (only feature changes)
|
|
test.concurrent(`${chalk.yellowBright("version-pricing: price unchanged")}`, 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: "version-pricing-unchanged",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Create v2 with same price but different included usage
|
|
const newMessagesItem = items.monthlyMessages({ includedUsage: 200 });
|
|
await autumnV1.products.update(pro.id, {
|
|
items: [newMessagesItem, priceItem],
|
|
});
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
version: 2,
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// No price change
|
|
expect(preview.total).toBe(0);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// 1.4 Add base price: v1 free → v2 $20
|
|
test.concurrent(`${chalk.yellowBright("version-pricing: add base price")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
// v1 has no base price (free)
|
|
const pro = products.base({ id: "pro", items: [messagesItem] });
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "version-pricing-add",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Create v2 with $20 base price
|
|
const priceItem = items.monthlyPrice({ price: 20 });
|
|
await autumnV1.products.update(pro.id, {
|
|
items: [messagesItem, priceItem],
|
|
});
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
version: 2,
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Should charge full $20 (prorated from $0)
|
|
expect(preview.total).toBe(20);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
await expectCustomerInvoiceCorrect({
|
|
customerId,
|
|
count: 1, // Only version upgrade invoice (no initial for free)
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|
|
|
|
// 1.5 Remove base price: v1 $20 → v2 free (subscription canceled, features remain)
|
|
test.concurrent(`${chalk.yellowBright("version-pricing: remove 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 } = await initScenario({
|
|
customerId: "version-pricing-remove",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Create v2 with no base price (free)
|
|
await autumnV1.products.update(pro.id, {
|
|
items: [messagesItem],
|
|
});
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
version: 2,
|
|
};
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Should credit full $20 (prorated to $0)
|
|
expect(preview.total).toBe(-20);
|
|
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Customer should still have the feature (now free)
|
|
expectCustomerFeatureCorrect({
|
|
customer,
|
|
featureId: TestFeature.Messages,
|
|
includedUsage: messagesItem.included_usage,
|
|
balance: messagesItem.included_usage,
|
|
usage: 0,
|
|
});
|
|
|
|
// Product should still be active
|
|
expect(
|
|
customer.products.find((p: ApiCusProductV3) => p.id === pro.id),
|
|
).toBeDefined();
|
|
|
|
// Should have 2 invoices (initial charge + credit for downgrade)
|
|
await expectCustomerInvoiceCorrect({
|
|
customerId,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
// Verify product is active but has no subscription IDs (free product)
|
|
const customerProduct = customer.products.find(
|
|
(p: ApiCusProductV3) => p.id === pro.id,
|
|
);
|
|
expect(customerProduct).toBeDefined();
|
|
expect(customerProduct?.stripe_subscription_ids?.length ?? 0).toBe(0);
|
|
});
|
|
|
|
// 1.6 Preview matches actual invoice
|
|
test.concurrent(`${chalk.yellowBright("version-pricing: preview matches invoice")}`, async () => {
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const priceItem = items.monthlyPrice({ price: 25 });
|
|
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
|
|
|
|
const { customerId, autumnV1, ctx } = await initScenario({
|
|
customerId: "version-pricing-preview-match",
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Create v2 with $45 price
|
|
const newPriceItem = items.monthlyPrice({ price: 45 });
|
|
await autumnV1.products.update(pro.id, {
|
|
items: [messagesItem, newPriceItem],
|
|
});
|
|
|
|
const updateParams = {
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
version: 2,
|
|
};
|
|
|
|
// Get preview first
|
|
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
|
|
|
// Execute update
|
|
await autumnV1.subscriptions.update(updateParams);
|
|
|
|
// Verify invoice matches preview exactly
|
|
await expectCustomerInvoiceCorrect({
|
|
customerId,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
// Also verify the expected amount ($45 - $25 = $20)
|
|
expect(preview.total).toBe(20);
|
|
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
});
|
|
});
|