import { expect, test } from "bun:test"; import { type ApiCustomerV3, OnDecrease, OnIncrease } from "@autumn/shared"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js"; import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; import { expectLatestInvoiceCorrect } from "@tests/integration/billing/utils/expectLatestInvoiceCorrect.js"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; /** * Multi-Entity Prepaid Quantity Tests * * These tests verify that prepaid quantity updates work correctly * when multiple entities share products or have different products. * * Key scenarios: * - Entity 1 increases quantity while Entity 2 remains unchanged * - Entity 2 decreases quantity while Entity 1 remains unchanged * - Cross-entity mixed changes (increase one, decrease other) * - OnDecrease.None config (no credit invoice on decrease) * - Different products per entity with quantity updates * - Multiple features per entity */ // Test 1: Basic Multi-Entity Increase test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 1 increases quantity")}`, async () => { const customerId = "multi-ent-qty-increase"; const billingUnits = 12; const pricePerUnit = 8; const prepaidItem = items.prepaid({ featureId: TestFeature.Messages, billingUnits, price: pricePerUnit, }); const product = products.base({ id: "prepaid", items: [prepaidItem], }); const initialQuantity1 = 10 * billingUnits; // 120 const initialQuantity2 = 5 * billingUnits; // 60 const newQuantity1 = 20 * billingUnits; // 240 const { autumnV1, ctx: testContext, entities, } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), s.products({ list: [product] }), s.entities({ count: 2, featureId: TestFeature.Users }), ], actions: [ s.attach({ productId: product.id, entityIndex: 0, options: [ { feature_id: TestFeature.Messages, quantity: initialQuantity1 }, ], }), s.attach({ productId: product.id, entityIndex: 1, options: [ { feature_id: TestFeature.Messages, quantity: initialQuantity2 }, ], }), ], }); // Preview the upgrade for entity 1 const preview = await autumnV1.subscriptions.previewUpdate({ customer_id: customerId, entity_id: entities[0].id, product_id: product.id, options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], }); // Expected: +10 units * $8 = $80 expect(preview.total).toBe(10 * pricePerUnit); // Execute the upgrade await autumnV1.subscriptions.update({ customer_id: customerId, entity_id: entities[0].id, product_id: product.id, options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], }); // Verify entity 1 has new balance const entity1 = await autumnV1.entities.get(customerId, entities[0].id); await expectCustomerFeatureCorrect({ customer: entity1, featureId: TestFeature.Messages, balance: newQuantity1, }); // Verify entity 2 is unchanged const entity2 = await autumnV1.entities.get(customerId, entities[1].id); await expectCustomerFeatureCorrect({ customer: entity2, featureId: TestFeature.Messages, balance: initialQuantity2, }); // Verify invoice const customer = await autumnV1.customers.get(customerId); expectLatestInvoiceCorrect({ customer, productId: product.id, amount: 10 * pricePerUnit, }); // Verify subscription count await expectSubToBeCorrect({ db: testContext.db, customerId, org: testContext.org, env: testContext.env, subCount: 1, }); }); // Test 2: Basic Multi-Entity Decrease test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 2 decreases quantity")}`, async () => { const customerId = "multi-ent-qty-decrease"; const billingUnits = 12; const pricePerUnit = 8; const prepaidItem = items.prepaid({ featureId: TestFeature.Messages, billingUnits, price: pricePerUnit, }); const product = products.base({ id: "prepaid", items: [prepaidItem], }); const initialQuantity1 = 10 * billingUnits; // 120 const initialQuantity2 = 15 * billingUnits; // 180 const newQuantity2 = 5 * billingUnits; // 60 const { autumnV1, entities } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), s.products({ list: [product] }), s.entities({ count: 2, featureId: TestFeature.Users }), ], actions: [ s.attach({ productId: product.id, entityIndex: 0, options: [ { feature_id: TestFeature.Messages, quantity: initialQuantity1 }, ], }), s.attach({ productId: product.id, entityIndex: 1, options: [ { feature_id: TestFeature.Messages, quantity: initialQuantity2 }, ], }), ], }); // Preview the downgrade for entity 2 const preview = await autumnV1.subscriptions.previewUpdate({ customer_id: customerId, entity_id: entities[1].id, product_id: product.id, options: [{ feature_id: TestFeature.Messages, quantity: newQuantity2 }], }); // Expected: -10 units * $8 = -$80 (credit) expect(preview.total).toBe(-10 * pricePerUnit); // Execute the downgrade await autumnV1.subscriptions.update({ customer_id: customerId, entity_id: entities[1].id, product_id: product.id, options: [{ feature_id: TestFeature.Messages, quantity: newQuantity2 }], }); // Verify entity 2 has new balance const entity2 = await autumnV1.entities.get(customerId, entities[1].id); await expectCustomerFeatureCorrect({ customer: entity2, featureId: TestFeature.Messages, balance: newQuantity2, }); // Verify entity 1 is unchanged const entity1 = await autumnV1.entities.get(customerId, entities[0].id); await expectCustomerFeatureCorrect({ customer: entity1, featureId: TestFeature.Messages, balance: initialQuantity1, }); // Verify credit invoice const customer = await autumnV1.customers.get(customerId); expectLatestInvoiceCorrect({ customer, productId: product.id, amount: -10 * pricePerUnit, }); }); // Test 3: Cross-Entity Mixed Changes test.concurrent(`${chalk.yellowBright("multi-entity-quantity: mixed changes across entities")}`, async () => { const customerId = "multi-ent-qty-mixed"; const billingUnits = 10; const pricePerUnit = 5; const prepaidItem = items.prepaid({ featureId: TestFeature.Messages, billingUnits, price: pricePerUnit, }); const product = products.base({ id: "prepaid", items: [prepaidItem], }); const initialQuantity1 = 10 * billingUnits; // 100 const initialQuantity2 = 20 * billingUnits; // 200 const newQuantity1 = 15 * billingUnits; // 150 (increase) const newQuantity2 = 10 * billingUnits; // 100 (decrease) const { autumnV1, entities } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), s.products({ list: [product] }), s.entities({ count: 2, featureId: TestFeature.Users }), ], actions: [ s.attach({ productId: product.id, entityIndex: 0, options: [ { feature_id: TestFeature.Messages, quantity: initialQuantity1 }, ], }), s.attach({ productId: product.id, entityIndex: 1, options: [ { feature_id: TestFeature.Messages, quantity: initialQuantity2 }, ], }), ], }); // Preview and execute increase for entity 1 const preview1 = await autumnV1.subscriptions.previewUpdate({ customer_id: customerId, entity_id: entities[0].id, product_id: product.id, options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], }); expect(preview1.total).toBe(5 * pricePerUnit); // +5 units * $5 await autumnV1.subscriptions.update({ customer_id: customerId, entity_id: entities[0].id, product_id: product.id, options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], }); // Preview and execute decrease for entity 2 const preview2 = await autumnV1.subscriptions.previewUpdate({ customer_id: customerId, entity_id: entities[1].id, product_id: product.id, options: [{ feature_id: TestFeature.Messages, quantity: newQuantity2 }], }); expect(preview2.total).toBe(-10 * pricePerUnit); // -10 units * $5 await autumnV1.subscriptions.update({ customer_id: customerId, entity_id: entities[1].id, product_id: product.id, options: [{ feature_id: TestFeature.Messages, quantity: newQuantity2 }], }); // Verify final balances const entity1 = await autumnV1.entities.get(customerId, entities[0].id); const entity2 = await autumnV1.entities.get(customerId, entities[1].id); await expectCustomerFeatureCorrect({ customer: entity1, featureId: TestFeature.Messages, balance: newQuantity1, }); await expectCustomerFeatureCorrect({ customer: entity2, featureId: TestFeature.Messages, balance: newQuantity2, }); // Verify customer has 4 invoices: // 2 from initial attaches + 1 from increase + 1 from decrease (credit) const customer = await autumnV1.customers.get(customerId); expectCustomerInvoiceCorrect({ customer, count: 4, }); }); // Test 4: OnDecrease.None Config - No Credit Invoice // With OnDecrease.None, the balance changes immediately but NO credit invoice is created // This differs from OnDecrease.ProrateImmediately which creates a credit invoice test.concurrent(`${chalk.yellowBright("multi-entity-quantity: OnDecrease.None creates no credit invoice")}`, async () => { const customerId = "multi-ent-qty-no-proration"; const billingUnits = 100; const pricePerUnit = 10; const prepaidItem = items.prepaid({ featureId: TestFeature.Messages, billingUnits, price: pricePerUnit, config: { on_increase: OnIncrease.ProrateImmediately, on_decrease: OnDecrease.None, }, }); const product = products.base({ id: "prepaid", items: [prepaidItem], }); const initialQuantity1 = 4 * billingUnits; // 400 const initialQuantity2 = 3 * billingUnits; // 300 const newQuantity1 = 2 * billingUnits; // 200 (decrease) const { autumnV1, entities, ctx, testClockId } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), s.products({ list: [product] }), s.entities({ count: 2, featureId: TestFeature.Users }), ], actions: [ s.attach({ productId: product.id, entityIndex: 0, options: [ { feature_id: TestFeature.Messages, quantity: initialQuantity1 }, ], }), s.attach({ productId: product.id, entityIndex: 1, options: [ { feature_id: TestFeature.Messages, quantity: initialQuantity2 }, ], }), ], }); const beforeInvoices = await autumnV1.customers.get(customerId); const invoiceCountBefore = beforeInvoices.invoices?.length || 0; // Preview the downgrade - should be $0 (no immediate credit) const preview = await autumnV1.subscriptions.previewUpdate({ customer_id: customerId, entity_id: entities[0].id, product_id: product.id, options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], }); expect(preview.total).toBe(0); // Execute the downgrade await autumnV1.subscriptions.update({ customer_id: customerId, entity_id: entities[0].id, product_id: product.id, options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], }); // Balance is updated AFTER the next cycle with OnDecrease.None const entity1After = await autumnV1.entities.get(customerId, entities[0].id); expectCustomerFeatureCorrect({ customer: entity1After, featureId: TestFeature.Messages, balance: initialQuantity1, }); // No new invoice should be created (the key behavior of OnDecrease.None) const afterUpdate = await autumnV1.customers.get(customerId); expectCustomerInvoiceCorrect({ customer: afterUpdate, count: invoiceCountBefore, }); // Entity 2 should be unchanged const entity2 = await autumnV1.entities.get(customerId, entities[1].id); expectCustomerFeatureCorrect({ customer: entity2, featureId: TestFeature.Messages, balance: initialQuantity2, }); await advanceToNextInvoice({ stripeCli: ctx.stripeCli, testClockId: testClockId!, }); const afterAdvance = await autumnV1.entities.get(customerId, entities[0].id); expectCustomerFeatureCorrect({ customer: afterAdvance, featureId: TestFeature.Messages, balance: newQuantity1, }); }); // Test 5: Different Products Per Entity test.concurrent(`${chalk.yellowBright("multi-entity-quantity: different products per entity")}`, async () => { const customerId = "multi-ent-qty-diff-products"; const billingUnits = 10; const prepaidBase = items.prepaid({ featureId: TestFeature.Messages, billingUnits, price: 5, }); const prepaidPro = items.prepaid({ featureId: TestFeature.Messages, billingUnits, price: 8, }); const baseProduct = products.base({ id: "base-prepaid", items: [prepaidBase], }); const proProduct = products.pro({ id: "pro-prepaid", items: [prepaidPro], }); const initialQuantityBase = 5 * billingUnits; // 50 const initialQuantityPro = 10 * billingUnits; // 100 const newQuantityPro = 15 * billingUnits; // 150 (increase) const { autumnV1, entities } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), s.products({ list: [baseProduct, proProduct] }), s.entities({ count: 2, featureId: TestFeature.Users }), ], actions: [ s.attach({ productId: baseProduct.id, entityIndex: 0, options: [ { feature_id: TestFeature.Messages, quantity: initialQuantityBase }, ], }), s.attach({ productId: proProduct.id, entityIndex: 1, options: [ { feature_id: TestFeature.Messages, quantity: initialQuantityPro }, ], }), ], }); // Preview upgrade for entity 2 (pro product) const preview = await autumnV1.subscriptions.previewUpdate({ customer_id: customerId, entity_id: entities[1].id, product_id: proProduct.id, options: [{ feature_id: TestFeature.Messages, quantity: newQuantityPro }], }); // +5 units * $8 = $40 expect(preview.total).toBe(5 * 8); // Execute the upgrade await autumnV1.subscriptions.update({ customer_id: customerId, entity_id: entities[1].id, product_id: proProduct.id, options: [{ feature_id: TestFeature.Messages, quantity: newQuantityPro }], }); // Verify entity 2 (pro) has new balance const entity2 = await autumnV1.entities.get(customerId, entities[1].id); await expectProductActive({ customer: entity2, productId: proProduct.id, }); await expectCustomerFeatureCorrect({ customer: entity2, featureId: TestFeature.Messages, balance: newQuantityPro, }); // Verify entity 1 (base) is unchanged const entity1 = await autumnV1.entities.get(customerId, entities[0].id); await expectProductActive({ customer: entity1, productId: baseProduct.id, }); await expectCustomerFeatureCorrect({ customer: entity1, featureId: TestFeature.Messages, balance: initialQuantityBase, }); // Verify invoice const customer = await autumnV1.customers.get(customerId); expectLatestInvoiceCorrect({ customer, productId: proProduct.id, amount: 5 * 8, }); }); // Test 6: Multiple Features Per Entity test.concurrent(`${chalk.yellowBright("multi-entity-quantity: multiple features per entity")}`, async () => { const customerId = "multi-ent-qty-multi-feat"; const messagesBillingUnits = 10; const wordsBillingUnits = 100; const messagesPrice = 5; const wordsPrice = 10; const messagesItem = items.prepaid({ featureId: TestFeature.Messages, billingUnits: messagesBillingUnits, price: messagesPrice, }); const wordsItem = items.prepaid({ featureId: TestFeature.Words, billingUnits: wordsBillingUnits, price: wordsPrice, }); const product = products.base({ id: "multi-feature", items: [messagesItem, wordsItem], }); const { autumnV1, entities } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), s.products({ list: [product] }), s.entities({ count: 2, featureId: TestFeature.Users }), ], actions: [ s.attach({ productId: product.id, entityIndex: 0, options: [ { feature_id: TestFeature.Messages, quantity: 5 * messagesBillingUnits, }, { feature_id: TestFeature.Words, quantity: 2 * wordsBillingUnits }, ], }), s.attach({ productId: product.id, entityIndex: 1, options: [ { feature_id: TestFeature.Messages, quantity: 10 * messagesBillingUnits, }, { feature_id: TestFeature.Words, quantity: 5 * wordsBillingUnits }, ], }), ], }); // Update entity 1: increase messages (+5 units), decrease words (-1 unit) const preview = await autumnV1.subscriptions.previewUpdate({ customer_id: customerId, entity_id: entities[0].id, product_id: product.id, options: [ { feature_id: TestFeature.Messages, quantity: 10 * messagesBillingUnits, }, // +5 units { feature_id: TestFeature.Words, quantity: 1 * wordsBillingUnits }, // -1 unit ], }); // Expected: +5 * $5 - 1 * $10 = $25 - $10 = $15 expect(preview.total).toBe(5 * messagesPrice - 1 * wordsPrice); await autumnV1.subscriptions.update({ customer_id: customerId, entity_id: entities[0].id, product_id: product.id, options: [ { feature_id: TestFeature.Messages, quantity: 10 * messagesBillingUnits, }, { feature_id: TestFeature.Words, quantity: 1 * wordsBillingUnits }, ], }); // Verify entity 1 features const entity1 = await autumnV1.entities.get(customerId, entities[0].id); await expectCustomerFeatureCorrect({ customer: entity1, featureId: TestFeature.Messages, balance: 100, }); await expectCustomerFeatureCorrect({ customer: entity1, featureId: TestFeature.Words, balance: 100, }); // Verify entity 2 is unchanged const entity2 = await autumnV1.entities.get(customerId, entities[1].id); await expectCustomerFeatureCorrect({ customer: entity2, featureId: TestFeature.Messages, balance: 100, }); await expectCustomerFeatureCorrect({ customer: entity2, featureId: TestFeature.Words, balance: 500, }); // Verify invoice const customer = await autumnV1.customers.get(customerId); expectLatestInvoiceCorrect({ customer, productId: product.id, amount: 5 * messagesPrice - 1 * wordsPrice, }); });