Files
cfw-autumn/server/tests/integration/billing/attach/immediate-switch/immediate-switch-per-entity.test.ts
2026-02-08 13:54:33 -08:00

533 lines
16 KiB
TypeScript

/**
* Immediate Switch Per-Entity Feature Tests (Attach V2)
*
* Tests for upgrade scenarios with per-entity features (entityFeatureId).
* Per-entity features give each entity its own balance allocation from a
* single customer-level product.
*
* Key behaviors tested:
* - Consumable features RESET usage on upgrade (same product)
* - Allocated (FREE) features CARRY OVER usage on upgrade (same product)
* - Per-entity with overage mid-cycle - is overage billed?
* - Upgrade from entity-level product to per-entity product
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
expectProductActive,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
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";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Upgrade with per-entity consumable + allocated features (mixed behavior)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Pro ($20/mo) with:
* - Per-entity consumable messages (500 per entity) - usage RESETS on upgrade
* - Per-entity FREE allocated workflows (10 per entity) - usage CARRIES OVER on upgrade
* - Create 2 entities
* - Track on entity 1: 200 messages, 5 workflows
* - Track on entity 2: 300 messages, 8 workflows
* - Upgrade to Premium ($50/mo) with:
* - Per-entity consumable messages (1000 per entity)
* - Per-entity FREE allocated workflows (20 per entity)
*
* Expected Result:
* - Premium is active, Pro is removed
* - Entity 1:
* - Messages: balance = 1000, usage = 0 (RESET)
* - Workflows: balance = 20 - 5 = 15, usage = 5 (CARRIED OVER)
* - Entity 2:
* - Messages: balance = 1000, usage = 0 (RESET)
* - Workflows: balance = 20 - 8 = 12, usage = 8 (CARRIED OVER)
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-per-entity 1: upgrade with consumable RESETS, allocated CARRIES OVER")}`, async () => {
const customerId = "imm-switch-pe-mixed-upgrade";
// Pro: per-entity consumable messages + per-entity FREE allocated workflows
const proPerEntityMessages = items.consumableMessages({
includedUsage: 500,
entityFeatureId: TestFeature.Users,
});
const proPerEntityWorkflows = items.freeAllocatedWorkflows({
includedUsage: 10,
entityFeatureId: TestFeature.Users,
});
const pro = products.pro({
id: "pro",
items: [proPerEntityMessages, proPerEntityWorkflows],
});
// Premium: higher per-entity limits
const premiumPerEntityMessages = items.consumableMessages({
includedUsage: 1000,
entityFeatureId: TestFeature.Users,
});
const premiumPerEntityWorkflows = items.freeAllocatedWorkflows({
includedUsage: 20,
entityFeatureId: TestFeature.Users,
});
const premium = products.premium({
id: "premium",
items: [premiumPerEntityMessages, premiumPerEntityWorkflows],
});
const { autumnV1, entities, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
// Attach per-entity product to customer (NOT to each entity)
s.billing.attach({ productId: pro.id, timeout: 4000 }),
// Track on entities
s.track({
featureId: TestFeature.Messages,
value: 200,
entityIndex: 0,
}),
s.track({
featureId: TestFeature.Messages,
value: 300,
entityIndex: 1,
}),
s.track({
featureId: TestFeature.Workflows,
value: 5,
entityIndex: 0,
}),
s.track({
featureId: TestFeature.Workflows,
value: 8,
entityIndex: 1,
timeout: 2000,
}),
],
});
// Verify entity balances before upgrade
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
expectCustomerFeatureCorrect({
customer: entity1Before,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 300, // 500 - 200
usage: 200,
});
expectCustomerFeatureCorrect({
customer: entity1Before,
featureId: TestFeature.Workflows,
includedUsage: 10,
balance: 5, // 10 - 5
usage: 5,
});
const entity2Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[1].id,
);
expectCustomerFeatureCorrect({
customer: entity2Before,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 200, // 500 - 300
usage: 300,
});
expectCustomerFeatureCorrect({
customer: entity2Before,
featureId: TestFeature.Workflows,
includedUsage: 10,
balance: 2, // 10 - 8
usage: 8,
});
// 1. Preview upgrade to premium
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
// $50 - $20 = $30
expect(preview.total).toBe(30);
// 2. Upgrade to premium
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify products
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
// Verify entity 1 after upgrade
const entity1After = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
// Messages: RESET (consumable)
expectCustomerFeatureCorrect({
customer: entity1After,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000, // RESET
usage: 0, // RESET
});
// Workflows: CARRIED OVER (allocated)
expectCustomerFeatureCorrect({
customer: entity1After,
featureId: TestFeature.Workflows,
includedUsage: 20,
balance: 15, // 20 - 5 (carried over)
usage: 5, // CARRIED OVER
});
// Verify entity 2 after upgrade
const entity2After = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[1].id,
);
// Messages: RESET (consumable)
expectCustomerFeatureCorrect({
customer: entity2After,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000, // RESET
usage: 0, // RESET
});
// Workflows: CARRIED OVER (allocated)
expectCustomerFeatureCorrect({
customer: entity2After,
featureId: TestFeature.Workflows,
includedUsage: 20,
balance: 12, // 20 - 8 (carried over)
usage: 8, // CARRIED OVER
});
// Verify customer totals
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 2000, // 1000 * 2
balance: 2000, // All reset
usage: 0,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Workflows,
includedUsage: 40, // 20 * 2
balance: 27, // 15 + 12
usage: 13, // 5 + 8
});
// Invoices: Pro ($20) + Upgrade ($30)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 30,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Upgrade per-entity with overage mid-cycle - overage billed immediately
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Pro ($20/mo) with per-entity consumable messages (100 per entity, $0.10/overage)
* - Create 2 entities
* - Track overage: entity 1 uses 150 (50 overage), entity 2 uses 200 (100 overage)
* - Upgrade to Premium ($50/mo) with 500 per entity
*
* Expected Result:
* - Premium is active
* - Overage from cycle is billed in upgrade invoice: 50 + 100 = 150 * $0.10 = $15
* - Total invoice: $30 (upgrade diff) + $15 (overage) = $45
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-per-entity 2: upgrade with overage mid-cycle - overage billed")}`, async () => {
const customerId = "imm-switch-pe-overage-mid-cycle";
// Pro: per-entity consumable with overage
const proPerEntityMessages = items.consumableMessages({
includedUsage: 100,
entityFeatureId: TestFeature.Users,
});
const pro = products.pro({
id: "pro",
items: [proPerEntityMessages],
});
// Premium: higher per-entity limits
const premiumPerEntityMessages = items.consumableMessages({
includedUsage: 500,
entityFeatureId: TestFeature.Users,
});
const premium = products.premium({
id: "premium",
items: [premiumPerEntityMessages],
});
const { autumnV1, entities, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
// Attach per-entity product to customer
s.billing.attach({ productId: pro.id }),
// Track overage on entities
s.track({
featureId: TestFeature.Messages,
value: 150, // 50 overage
entityIndex: 0,
}),
s.track({
featureId: TestFeature.Messages,
value: 200, // 100 overage
entityIndex: 1,
timeout: 2000,
}),
],
});
// Verify overage before upgrade
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
expectCustomerFeatureCorrect({
customer: entity1Before,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: -50, // 100 - 150 = -50 overage
usage: 150,
});
const entity2Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[1].id,
);
expectCustomerFeatureCorrect({
customer: entity2Before,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: -100, // 100 - 200 = -100 overage
usage: 200,
});
// 1. Preview upgrade to premium
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
// Expected: $30 (upgrade diff) + $15 (overage: 150 * $0.10) = $45
expect(preview.total).toBe(45);
// 2. Upgrade to premium
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify products
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
// Verify entities after upgrade - usage reset
const entity1After = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
expectCustomerFeatureCorrect({
customer: entity1After,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500, // RESET
usage: 0, // RESET
});
const entity2After = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[1].id,
);
expectCustomerFeatureCorrect({
customer: entity2After,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500, // RESET
usage: 0, // RESET
});
// Invoices: Pro ($20) + Upgrade with overage ($45)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 45, // $30 upgrade + $15 overage
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Entity-level product + per-entity product coexistence
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Entity 1 has its own Pro product ($20/mo, 500 messages) attached at entity-level
* - Add customer-level per-entity Premium product ($50/mo, 1000 messages per entity)
* - Both should coexist (different scopes: entity-level vs customer-level per-entity)
*
* Expected Result:
* - Both products are active
* - Entity 1 gets: 500 (entity-level) + 1000 (per-entity) = 1500 messages
* - Entity 2 gets: 1000 (per-entity only)
* - Customer total: 1500 + 1000 = 2500
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-per-entity 3: entity-level product + per-entity product coexist")}`, async () => {
const customerId = "imm-switch-pe-entity-level-coexist";
// Pro: entity-level product (NOT per-entity)
const proMessages = items.consumableMessages({
includedUsage: 500,
// No entityFeatureId = regular customer/entity-level product
});
const pro = products.pro({
id: "pro",
items: [proMessages],
});
// Premium: per-entity product
const premiumPerEntityMessages = items.consumableMessages({
includedUsage: 1000,
entityFeatureId: TestFeature.Users,
});
const premium = products.premium({
id: "premium",
items: [premiumPerEntityMessages],
});
const { autumnV1, entities, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
// Attach Pro at entity-level to entity 1 only
s.billing.attach({ productId: pro.id, entityIndex: 0, timeout: 4000 }),
// Attach Premium as per-entity product to customer (all entities get allocation)
s.billing.attach({ productId: premium.id, timeout: 4000 }),
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Both products should be active
await expectCustomerProducts({
customer,
active: [pro.id, premium.id],
});
// Verify entity 1: has BOTH entity-level Pro + per-entity Premium
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
// Entity 1 should have Pro active at entity level
expectProductActive({
customer: entity1,
productId: pro.id,
});
// Entity 1 messages: 500 (entity-level Pro) + 1000 (per-entity Premium) = 1500
expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Messages,
includedUsage: 1500, // 500 + 1000
balance: 1500,
usage: 0,
});
// Verify entity 2: has ONLY per-entity Premium (no entity-level product)
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[1].id,
);
// Entity 2 messages: 1000 (per-entity Premium only)
expectCustomerFeatureCorrect({
customer: entity2,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
});
// Verify customer totals: 1500 (entity 1) + 1000 (entity 2) = 2500
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 2500, // 500 + 1000 + 1000
balance: 2500,
usage: 0,
});
// Invoices: Pro at entity-level ($20) + Premium per-entity ($50) = 2 invoices
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 50, // Premium invoice
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});