Files
cfw-autumn/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-per-entity-consumable.test.ts
John Yeo 56f3826cc2 wip
2026-02-07 19:57:55 -08:00

323 lines
12 KiB
TypeScript

/**
* Invoice Created Webhook Tests - Per-Entity Consumable Prices (Basic)
*
* Tests for handling the `invoice.created` Stripe webhook event for per-entity
* consumable (usage-in-arrear) prices. Covers basic overage scenarios:
* multiple entities with varying overage, billing units rounding, and mixed usage.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
import { calculateExpectedInvoiceAmount } from "@tests/integration/billing/utils/calculateExpectedInvoiceAmount";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
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 A: Per entity consumable with included usage - multiple entities
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has Pro ($20/month) with per-entity consumable messages
* (100 included per entity, $0.10/unit overage)
* - 3 entities are created (each gets 100 included)
* - Product is attached ONCE to the customer
* - Entity 1: Track 150 messages (50 overage)
* - Entity 2: Track 250 messages (150 overage)
* - Entity 3: Track 350 messages (250 overage)
* - Advance to next billing cycle
*
* Expected Result:
* - Total overage: 50 + 150 + 250 = 450 units * $0.10 = $45
* - Final invoice: $20 base (single) + $45 overage = $65
* - All entity balances reset to 100 after cycle
*/
test.concurrent(`${chalk.yellowBright("invoice.created per-entity consumable: 3 entities with varying overage → advance cycle")}`, async () => {
const customerId = "inv-pe-cons-3ent";
// Create per-entity consumable messages (100 included per entity)
const perEntityConsumable = items.consumableMessages({
includedUsage: 100,
entityFeatureId: TestFeature.Users,
});
const pro = products.pro({
id: "pro",
items: [perEntityConsumable],
});
const { autumnV1, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
s.entities({ count: 3, featureId: TestFeature.Users }),
],
actions: [
// Attach product ONCE to the customer (not per entity)
s.attach({ productId: pro.id }),
// Track varying usage on each entity (must specify entityIndex for per-entity features)
s.track({ featureId: TestFeature.Messages, value: 150, entityIndex: 0 }), // 50 overage
s.track({ featureId: TestFeature.Messages, value: 250, entityIndex: 1 }), // 150 overage
s.track({ featureId: TestFeature.Messages, value: 350, entityIndex: 2 }), // 250 overage
s.advanceToNextInvoice({ withPause: true }),
],
});
// Verify overage calculations
// Each entity: (usage - 100 included) * $0.10
// Entity 1: (150 - 100) * 0.10 = $5
// Entity 2: (250 - 100) * 0.10 = $15
// Entity 3: (350 - 100) * 0.10 = $25
// Total overage: $45
const entity1Overage = calculateExpectedInvoiceAmount({
items: pro.items,
usage: [{ featureId: TestFeature.Messages, value: 150 }],
options: { includeFixed: false, onlyArrear: true },
});
expect(entity1Overage).toBe(5);
const entity2Overage = calculateExpectedInvoiceAmount({
items: pro.items,
usage: [{ featureId: TestFeature.Messages, value: 250 }],
options: { includeFixed: false, onlyArrear: true },
});
expect(entity2Overage).toBe(15);
const entity3Overage = calculateExpectedInvoiceAmount({
items: pro.items,
usage: [{ featureId: TestFeature.Messages, value: 350 }],
options: { includeFixed: false, onlyArrear: true },
});
expect(entity3Overage).toBe(25);
const totalOverage = entity1Overage + entity2Overage + entity3Overage;
expect(totalOverage).toBe(45);
// Verify final customer state
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Should have 2 invoices: 1 initial attach ($20) + 1 renewal ($20 base + $45 overage = $65)
expectCustomerInvoiceCorrect({
customer: customerAfter,
count: 2,
latestTotal: 20 + totalOverage, // $65
});
// Verify each entity balance is reset to 100
for (const entity of entities) {
const entityData = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity.id,
);
await expectProductActive({
customer: entityData,
productId: pro.id,
});
expectCustomerFeatureCorrect({
customer: entityData,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
});
}
// Verify customer total (sum of all entities)
expectCustomerFeatureCorrect({
customer: customerAfter,
featureId: TestFeature.Messages,
includedUsage: 100 * 3, // 300 total included
balance: 100 * 3, // 300 total balance after reset
usage: 0,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST B: Per entity consumable with billing units > 1 (sum then round)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has Pro ($20/month) with per-entity consumable messages
* (100 included per entity, $1/10 units, billingUnits=10)
* - 2 entities are created
* - Product is attached ONCE to the customer
* - Entity 1: Track 155 messages → 55 overage
* - Entity 2: Track 123 messages → 23 overage
* - Advance to next billing cycle
*
* Expected Result:
* - Total overage: 55 + 23 = 78 → rounds UP to 80 (billingUnits=10)
* - Charge: ceil(78/10) * $1 = 8 * $1 = $8
* - Final invoice: $20 base (single) + $8 overage = $28
*
* IMPORTANT: For per-entity consumables, ALL entity overages are SUMMED FIRST,
* then the TOTAL is rounded up to billing units. NOT rounded per-entity then summed.
*/
test.concurrent(`${chalk.yellowBright("invoice.created per-entity consumable: billing units - sum then round → advance cycle")}`, async () => {
const customerId = "inv-pe-cons-billing-units";
// Create per-entity consumable with billingUnits=10, $1 per 10 units
const perEntityConsumable = items.consumable({
featureId: TestFeature.Messages,
includedUsage: 100,
price: 1, // $1 per 10 units
billingUnits: 10,
entityFeatureId: TestFeature.Users,
});
const pro = products.pro({
id: "pro",
items: [perEntityConsumable],
});
const { autumnV1, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Messages, value: 155, entityIndex: 0 }), // 55 overage
s.track({ featureId: TestFeature.Messages, value: 123, entityIndex: 1 }), // 23 overage
s.advanceToNextInvoice({ withPause: true }),
],
});
// Per-entity consumables: sum all overages first, then round up total
// Entity 1: 155 - 100 = 55 overage
// Entity 2: 123 - 100 = 23 overage
// Total overage: 55 + 23 = 78 → ceil(78/10) = 8 → 8 * $1 = $8
const totalOverage = 8;
// Verify final state
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// 1 initial invoice ($20) + 1 renewal ($20 base + $8 overage = $28)
expectCustomerInvoiceCorrect({
customer: customerAfter,
count: 2,
latestTotal: 20 + totalOverage, // $28
});
// Verify entity balances reset
for (const entity of entities) {
const entityData = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity.id,
);
expectCustomerFeatureCorrect({
customer: entityData,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
});
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST C: Per entity consumable - some entities in overage, some within included
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has Pro ($20/month) with per-entity consumable messages
* (100 included per entity, $0.10/unit overage)
* - 3 entities
* - Product is attached ONCE to the customer
* - Entity 1: Track 50 messages (NO overage - within included)
* - Entity 2: Track 100 messages (NO overage - exactly at included)
* - Entity 3: Track 200 messages (100 overage)
* - Advance to next billing cycle
*
* Expected Result:
* - Only Entity 3 has overage: 100 * $0.10 = $10
* - Final invoice: $20 base (single) + $10 overage = $30
*/
test.concurrent(`${chalk.yellowBright("invoice.created per-entity consumable: mixed usage - some in overage, some within included")}`, async () => {
const customerId = "inv-pe-cons-mixed";
const perEntityConsumable = items.consumableMessages({
includedUsage: 100,
entityFeatureId: TestFeature.Users,
});
const pro = products.pro({
id: "pro",
items: [perEntityConsumable],
});
const { autumnV1, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
s.entities({ count: 3, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Messages, value: 50, entityIndex: 0 }), // Within included
s.track({ featureId: TestFeature.Messages, value: 100, entityIndex: 1 }), // Exactly at included
s.track({ featureId: TestFeature.Messages, value: 200, entityIndex: 2 }), // 100 overage
s.advanceToNextInvoice({ withPause: true }),
],
});
// Verify individual overages
const entity1Overage = calculateExpectedInvoiceAmount({
items: pro.items,
usage: [{ featureId: TestFeature.Messages, value: 50 }],
options: { includeFixed: false, onlyArrear: true },
});
expect(entity1Overage).toBe(0); // No overage
const entity2Overage = calculateExpectedInvoiceAmount({
items: pro.items,
usage: [{ featureId: TestFeature.Messages, value: 100 }],
options: { includeFixed: false, onlyArrear: true },
});
expect(entity2Overage).toBe(0); // No overage
const entity3Overage = calculateExpectedInvoiceAmount({
items: pro.items,
usage: [{ featureId: TestFeature.Messages, value: 200 }],
options: { includeFixed: false, onlyArrear: true },
});
expect(entity3Overage).toBe(10); // 100 * $0.10
// Verify final state
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// 1 initial invoice ($20) + 1 renewal ($20 base + $10 overage = $30)
expectCustomerInvoiceCorrect({
customer: customerAfter,
count: 2,
latestTotal: 20 + 10, // $30
});
// Verify all entity balances reset to 100
for (const entity of entities) {
const entityData = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity.id,
);
expectCustomerFeatureCorrect({
customer: entityData,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
});
}
});