diff --git a/server/tests/integration/billing/update-subscription/cancel/cancel-consumable-entities.test.ts b/server/tests/integration/billing/update-subscription/cancel/cancel-consumable-entities.test.ts new file mode 100644 index 000000000..521e278a4 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/cancel/cancel-consumable-entities.test.ts @@ -0,0 +1,688 @@ +/** + * Cancel Consumable Entity Tests + * + * Tests for canceling entity-level products with consumable/arrear items (pay-per-use overage). + * Consumable items create a final invoice at end of cycle for any overage usage. + * + * Key behaviors: + * - Overage usage is billed at cycle end (arrear pricing) + * - Cancel end of cycle: overage billed in final invoice when cycle ends naturally + * - Cancel immediately: NO overage billed - only base price refund (arrear overages not charged on cancel) + * - Default update subscription behavior does NOT charge for arrear overages + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { calculateExpectedInvoiceAmount } from "@tests/integration/billing/utils/calculateExpectedInvoiceAmount"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductActive, + expectProductCanceling, + expectProductNotPresent, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription"; +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 { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Track → cancel end of cycle → advance (entity) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity has Pro with consumable messages (100 included, $0.10/unit overage) + * - Track 500 messages on entity (400 overage) + * - Cancel entity's product end of cycle + * - Advance to next invoice + * + * Expected Result: + * - Initial invoice: $20 (pro base price) + * - Final invoice: $40 (400 overage * $0.10) + * - Entity product removed after cycle ends + * + * Migrated from: entity3.test.ts + */ +test.concurrent(`${chalk.yellowBright("cancel consumable entity: track → cancel end of cycle → advance")}`, async () => { + const customerId = "cancel-cons-eoc-ent"; + + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, ctx, testClockId, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entityId = entities[0].id; + + // Verify pro is active on entity + const entity = await autumnV1.entities.get(customerId, entityId); + await expectProductActive({ + customer: entity, + productId: pro.id, + }); + + // Track 500 messages on entity (100 included, 400 overage) + await autumnV1.track({ + customer_id: customerId, + entity_id: entityId, + feature_id: TestFeature.Messages, + value: 500, + }); + + // Cancel entity's product end of cycle + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entityId, + product_id: pro.id, + cancel: "end_of_cycle", + }); + + // Verify pro is canceling on entity + const entityAfterCancel = await autumnV1.entities.get(customerId, entityId); + await expectProductCanceling({ + customer: entityAfterCancel, + productId: pro.id, + }); + + // Advance to next invoice + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + // Verify final state + const entityAfterAdvance = await autumnV1.entities.get(customerId, entityId); + + // Product should be removed from entity + await expectProductNotPresent({ + customer: entityAfterAdvance, + productId: pro.id, + }); + + // Check customer invoices (invoices are at customer level) + const customerAfterAdvance = + await autumnV1.customers.get(customerId); + + // Should have 2 invoices: initial ($20) + final overage ($40) + expect(customerAfterAdvance.invoices?.length).toBe(2); + + // Final invoice should be overage: 400 * $0.10 = $40 + expect(customerAfterAdvance.invoices?.[0].total).toBe(40); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Track → cancel immediately (entity) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity has Pro with consumable messages (100 included, $0.10/unit overage) + * - Track 500 messages on entity (400 overage) + * - Cancel entity's product immediately + * + * Expected Result: + * - Initial invoice: $20 (pro base price) + * - Final invoice: -$20 (base refund only, no overage - arrear overages not charged on cancel) + * - Entity product removed immediately + */ +test.concurrent(`${chalk.yellowBright("cancel consumable entity: track → cancel immediately - no overage charge")}`, async () => { + const customerId = "cancel-cons-imm-ent"; + + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entityId = entities[0].id; + + // Verify pro is active on entity + const entity = await autumnV1.entities.get(customerId, entityId); + await expectProductActive({ + customer: entity, + productId: pro.id, + }); + + // Track 500 messages on entity (100 included, 400 overage) + // Note: This overage will NOT be charged when canceling immediately + await autumnV1.track({ + customer_id: customerId, + entity_id: entityId, + feature_id: TestFeature.Messages, + value: 500, + }); + + // Preview cancel immediately + const cancelParams = { + customer_id: customerId, + entity_id: entityId, + product_id: pro.id, + cancel: "immediately" as const, + }; + const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); + + // Final invoice = base refund only (-$20), no overage charged on cancel + expect(preview.total).toBe(-20); + + // Execute cancel + await autumnV1.subscriptions.update(cancelParams); + + // Verify pro is removed from entity + const entityAfterCancel = await autumnV1.entities.get(customerId, entityId); + await expectProductNotPresent({ + customer: entityAfterCancel, + productId: pro.id, + }); + + // Verify no Stripe subscription exists + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + + // Check customer invoices + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + // Should have 2 invoices: initial ($20) + final (refund -$20) + expect(customerAfterCancel.invoices?.length).toBe(2); + + // Verify final invoice matches preview (refund only) + expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 2, + latestTotal: preview.total, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Two entities, both overage, cancel end of cycle → advance +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity 1 and Entity 2 both have Pro with consumable messages + * - Track usage into overage on both entities + * - Cancel both end of cycle + * - Advance to next invoice + * + * Expected Result: + * - Both entities' overage billed in final invoices + * - Both products removed after cycle ends + */ +test.concurrent(`${chalk.yellowBright("cancel consumable entity: two entities, both overage, cancel end of cycle")}`, async () => { + const customerId = "cancel-cons-2ent-eoc"; + + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, ctx, testClockId, 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, entityIndex: 0 }), + s.attach({ productId: pro.id, entityIndex: 1, timeout: 3000 }), + ], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify pro is active on both entities + const entity1 = await autumnV1.entities.get(customerId, entity1Id); + const entity2 = await autumnV1.entities.get(customerId, entity2Id); + await expectProductActive({ customer: entity1, productId: pro.id }); + await expectProductActive({ customer: entity2, productId: pro.id }); + + // Verify initial invoices: 2 invoices, $20 each for entity attaches + const customerAfterAttach = + await autumnV1.customers.get(customerId); + expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 2, + latestTotal: 20, + }); + + // Track usage on entity 1: 300 messages (200 overage = $20) + await autumnV1.track({ + customer_id: customerId, + entity_id: entity1Id, + feature_id: TestFeature.Messages, + value: 300, + }); + + // Track usage on entity 2: 600 messages (500 overage = $50) + await autumnV1.track({ + customer_id: customerId, + entity_id: entity2Id, + feature_id: TestFeature.Messages, + value: 600, + }); + + // Cancel both entities end of cycle + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entity1Id, + product_id: pro.id, + cancel: "end_of_cycle", + }); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entity2Id, + product_id: pro.id, + cancel: "end_of_cycle", + }); + + // Verify both are canceling + const entity1AfterCancel = await autumnV1.entities.get(customerId, entity1Id); + const entity2AfterCancel = await autumnV1.entities.get(customerId, entity2Id); + await expectProductCanceling({ + customer: entity1AfterCancel, + productId: pro.id, + }); + await expectProductCanceling({ + customer: entity2AfterCancel, + productId: pro.id, + }); + + // Advance to next invoice + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + // Verify both products removed + const entity1Final = await autumnV1.entities.get(customerId, entity1Id); + const entity2Final = await autumnV1.entities.get(customerId, entity2Id); + await expectProductNotPresent({ customer: entity1Final, productId: pro.id }); + await expectProductNotPresent({ customer: entity2Final, productId: pro.id }); + + // Calculate expected overage amounts + // Entity 1: 300 messages - 100 included = 200 overage * $0.10 = $20 + // Entity 2: 600 messages - 100 included = 500 overage * $0.10 = $50 + const entity1Overage = calculateExpectedInvoiceAmount({ + items: pro.items, + usage: [{ featureId: TestFeature.Messages, value: 300 }], + options: { includeFixed: false, onlyArrear: true }, + }); + const entity2Overage = calculateExpectedInvoiceAmount({ + items: pro.items, + usage: [{ featureId: TestFeature.Messages, value: 600 }], + options: { includeFixed: false, onlyArrear: true }, + }); + expect(entity1Overage).toBe(20); + expect(entity2Overage).toBe(50); + + const totalOverage = entity1Overage + entity2Overage; + expect(totalOverage).toBe(70); + + // Check customer invoices + const customerFinal = await autumnV1.customers.get(customerId); + + // Should have 3 invoices: + // - 2 initial invoices ($20 each for entity attaches) + // - 1 final invoice ($70 for combined overage from both entities) + expectCustomerInvoiceCorrect({ + customer: customerFinal, + count: 3, + latestTotal: totalOverage, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Two entities, cancel one end of cycle, keep one active +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity 1 and Entity 2 both have Pro with consumable messages + * - Track usage into overage on both entities + * - Cancel only Entity 1 end of cycle + * - Keep Entity 2 active + * - Advance to next invoice + * + * Expected Result: + * - Entity 1's overage billed, product removed + * - Entity 2 continues with subscription, renews normally + */ +test.concurrent(`${chalk.yellowBright("cancel consumable entity: two entities, cancel one end of cycle, keep one active")}`, async () => { + const customerId = "cancel-cons-2ent-1eoc"; + + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, ctx, testClockId, 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, entityIndex: 0 }), + s.attach({ productId: pro.id, entityIndex: 1, timeout: 3000 }), + ], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify initial invoices: 2 invoices, $20 each for entity attaches + const customerAfterAttach = + await autumnV1.customers.get(customerId); + expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 2, + latestTotal: 20, + }); + + // Track usage on entity 1: 400 messages (300 overage = $30) + await autumnV1.track({ + customer_id: customerId, + entity_id: entity1Id, + feature_id: TestFeature.Messages, + value: 400, + }); + + // Track usage on entity 2: 200 messages (100 overage = $10) + await autumnV1.track({ + customer_id: customerId, + entity_id: entity2Id, + feature_id: TestFeature.Messages, + value: 200, + }); + + // Cancel only entity 1 end of cycle + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entity1Id, + product_id: pro.id, + cancel: "end_of_cycle", + }); + + // Verify entity 1 is canceling, entity 2 is still active + const entity1AfterCancel = await autumnV1.entities.get(customerId, entity1Id); + const entity2AfterCancel = await autumnV1.entities.get(customerId, entity2Id); + await expectProductCanceling({ + customer: entity1AfterCancel, + productId: pro.id, + }); + await expectProductActive({ + customer: entity2AfterCancel, + productId: pro.id, + }); + + // Advance to next invoice + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + withPause: true, + }); + + // Verify entity 1 product removed, entity 2 still active + const entity1Final = await autumnV1.entities.get(customerId, entity1Id); + const entity2Final = await autumnV1.entities.get(customerId, entity2Id); + await expectProductNotPresent({ customer: entity1Final, productId: pro.id }); + await expectProductActive({ customer: entity2Final, productId: pro.id }); + + // Entity 2's balance should be reset (new cycle) + expect(entity2Final.features[TestFeature.Messages].balance).toBe(100); + + // Calculate expected amounts + // Entity 1: 400 messages - 100 included = 300 overage * $0.10 = $30 + // Entity 2: 200 messages - 100 included = 100 overage * $0.10 = $10 + // Entity 2 also renews: $20 base price + const entity1Overage = calculateExpectedInvoiceAmount({ + items: pro.items, + usage: [{ featureId: TestFeature.Messages, value: 400 }], + options: { includeFixed: false, onlyArrear: true }, + }); + const entity2Overage = calculateExpectedInvoiceAmount({ + items: pro.items, + usage: [{ featureId: TestFeature.Messages, value: 200 }], + options: { includeFixed: false, onlyArrear: true }, + }); + expect(entity1Overage).toBe(30); + expect(entity2Overage).toBe(10); + + // Check customer invoices + const customerFinal = await autumnV1.customers.get(customerId); + + // Should have 4 invoices: + // - 2 initial invoices ($20 each for entity attaches) + // - 1 final invoice for entity 1 overage ($30) + entity 2 overage ($10) + entity 2 renewal ($20) = $60 + expectCustomerInvoiceCorrect({ + customer: customerFinal, + count: 3, + latestTotal: entity1Overage + entity2Overage + 20, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: Two entities, cancel one immediately, keep one active +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity 1 and Entity 2 both have Pro with consumable messages + * - Track usage into overage on both entities + * - Cancel only Entity 1 immediately + * - Keep Entity 2 active + * + * Expected Result: + * - Entity 1's final invoice: -$20 (base refund only, no overage charge) + * - Entity 1 product removed immediately + * - Entity 2 continues unaffected + */ +test.concurrent(`${chalk.yellowBright("cancel consumable entity: two entities, cancel one immediately, keep one active - no overage charge")}`, async () => { + const customerId = "cancel-cons-2ent-1imm"; + + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, ctx, 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, entityIndex: 0 }), + s.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify initial invoices: 2 invoices, $20 each for entity attaches + const customerAfterAttach = + await autumnV1.customers.get(customerId); + expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 2, + latestTotal: 20, + }); + + // Track usage on entity 1: 500 messages (400 overage) + // Note: This overage will NOT be charged when canceling immediately + await autumnV1.track({ + customer_id: customerId, + entity_id: entity1Id, + feature_id: TestFeature.Messages, + value: 500, + }); + + // Track usage on entity 2: 150 messages (50 overage) + await autumnV1.track({ + customer_id: customerId, + entity_id: entity2Id, + feature_id: TestFeature.Messages, + value: 150, + }); + + // Cancel only entity 1 immediately + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entity1Id, + product_id: pro.id, + cancel: "immediately", + }); + + // Verify entity 1 product removed immediately, entity 2 still active + const entity1AfterCancel = await autumnV1.entities.get(customerId, entity1Id); + const entity2AfterCancel = await autumnV1.entities.get(customerId, entity2Id); + await expectProductNotPresent({ + customer: entity1AfterCancel, + productId: pro.id, + }); + await expectProductActive({ + customer: entity2AfterCancel, + productId: pro.id, + }); + + // Check customer invoices + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + // Should have 3 invoices: + // - 2 initial invoices ($20 each for entity attaches) + // - 1 final invoice for entity 1 refund (-$20, no overage charged on cancel) + expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 3, + latestTotal: -20, + }); + + // Verify entity 2 has the product and subscription exists + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + subCount: 1, // Entity 2's subscription should still exist + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 6: Entity within included usage, cancel immediately (no overage) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Entity has Pro with consumable messages (100 included) + * - Track 50 messages (within included usage, no overage) + * - Cancel immediately + * + * Expected Result: + * - No overage invoice (usage within included) + * - Only refund invoice for unused time (if applicable) + * - Product removed immediately + */ +test.concurrent(`${chalk.yellowBright("cancel consumable entity: within included usage, cancel immediately")}`, async () => { + const customerId = "cancel-cons-no-overage"; + + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entityId = entities[0].id; + + // Verify pro is active on entity + const entity = await autumnV1.entities.get(customerId, entityId); + await expectProductActive({ + customer: entity, + productId: pro.id, + }); + + // Track 50 messages (within 100 included, no overage) + await autumnV1.track({ + customer_id: customerId, + entity_id: entityId, + feature_id: TestFeature.Messages, + value: 50, + }); + + // Verify balance is correct (100 - 50 = 50) + const entityAfterTrack = await autumnV1.entities.get(customerId, entityId); + expect(entityAfterTrack.features[TestFeature.Messages].balance).toBe(50); + + // Cancel immediately + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entityId, + product_id: pro.id, + cancel: "immediately", + }); + + // Verify product removed + const entityAfterCancel = await autumnV1.entities.get(customerId, entityId); + await expectProductNotPresent({ + customer: entityAfterCancel, + productId: pro.id, + }); + + // Check invoices - should have initial ($20) and possibly refund, but NO overage charge + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + // Verify the final invoice (if exists) is a refund (negative or zero), not an overage charge + if (customerAfterCancel.invoices && customerAfterCancel.invoices.length > 1) { + const finalInvoice = customerAfterCancel.invoices[0]; + // Final invoice should be refund (negative) or small, not a large overage charge + expect(finalInvoice.total).toBeLessThanOrEqual(0); + } +}); diff --git a/server/tests/integration/billing/update-subscription/cancel/cancel-consumable.test.ts b/server/tests/integration/billing/update-subscription/cancel/cancel-consumable.test.ts index 4704697f8..2251d3d4b 100644 --- a/server/tests/integration/billing/update-subscription/cancel/cancel-consumable.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/cancel-consumable.test.ts @@ -1,14 +1,16 @@ /** - * Cancel Consumable Tests + * Cancel Consumable Tests (Customer-Level) * - * Tests for canceling products with consumable/arrear items (pay-per-use overage). + * Tests for canceling customer-level products with consumable/arrear items (pay-per-use overage). * Consumable items create a final invoice at end of cycle for any overage usage. * * Key behaviors: * - Overage usage is billed at cycle end (arrear pricing) - * - Cancel end of cycle: overage billed in final invoice when cycle ends - * - Cancel immediately: overage billed in final invoice immediately - * - No double charge for base price on cancel immediately (refund + final invoice) + * - Cancel end of cycle: overage billed in final invoice when cycle ends naturally + * - Cancel immediately: NO overage billed - only base price refund (arrear overages not charged on cancel) + * - Default update subscription behavior does NOT charge for arrear overages + * + * For entity-level tests, see cancel-consumable-entities.test.ts */ import { expect, test } from "bun:test"; @@ -21,16 +23,146 @@ import { expectProductNotPresent, } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription"; -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 { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { addDays } from "date-fns"; import chalk from "chalk"; // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 1: Track → cancel end of cycle → advance (customer-level) +// TEST 1: Attach → track overage → advance days → cancel immediately (no overage in invoice) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has Pro with consumable messages (100 included, $0.10/unit overage) + * - Track 500 messages (400 overage) + * - Advance test clock a couple of days (mid-cycle) + * - Cancel immediately + * + * Expected Result: + * - Initial invoice: $20 (pro base price) + * - Final invoice: prorated refund only (NO overage charge) + * - handleInvoiceCreated is NOT triggered for usage/consumable prices on immediate cancel + * - Product removed immediately + * + * This test verifies that the invoice.created webhook handler does NOT add + * arrear/consumable usage charges when canceling immediately mid-cycle. + */ +test.concurrent(`${chalk.yellowBright("cancel consumable: attach → track overage → advance days → cancel immediately (no overage in invoice)")}`, async () => { + const customerId = "cancel-cons-adv-imm"; + + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1Beta, ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Verify pro is active + const customerAfterAttach = + await autumnV1Beta.customers.get(customerId); + await expectProductActive({ + customer: customerAfterAttach, + productId: pro.id, + }); + + // Initial attach invoice: $20 base price + expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 20, + }); + + // Track 500 messages (100 included, 400 overage) + // Note: This overage will NOT be charged when canceling immediately + await autumnV1Beta.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 500, + }); + + // Verify usage was tracked (400 overage = balance should be -400) + const customerAfterTrack = + await autumnV1Beta.customers.get(customerId); + expect(customerAfterTrack.features[TestFeature.Messages].balance).toBe(-400); + + // Advance test clock 5 days mid-cycle + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addDays(new Date(), 5).getTime(), + waitForSeconds: 10, + }); + + // Preview cancel immediately + const cancelParams = { + customer_id: customerId, + product_id: pro.id, + cancel: "immediately" as const, + }; + const preview = await autumnV1Beta.subscriptions.previewUpdate(cancelParams); + + // Final invoice should be a prorated refund only (negative), NO overage + // After 5 days of a ~30 day cycle, refund is roughly (25/30) * $20 ≈ -$16.67 + // The key assertion: invoice should be NEGATIVE (refund only, no overage charge) + expect(preview.total).toBeLessThan(0); + + // Execute cancel + await autumnV1Beta.subscriptions.update(cancelParams); + + // Verify pro is removed + const customerAfterCancel = + await autumnV1Beta.customers.get(customerId); + await expectProductNotPresent({ + customer: customerAfterCancel, + productId: pro.id, + }); + + // Verify no Stripe subscription exists + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + + // Should have 2 invoices: initial ($20) + final (refund, negative) + expect(customerAfterCancel.invoices?.length).toBe(2); + + // Final invoice should match preview (negative = refund only, no overage) + const finalInvoice = customerAfterCancel.invoices?.[0]; + expect(finalInvoice?.total).toBe(preview.total); + expect(finalInvoice?.total).toBeLessThan(0); + + // Calculate what overage WOULD have been if charged: 400 * $0.10 = $40 + // If overage was incorrectly included, total would be: $40 - refund ≈ $23+ + // By asserting total < 0, we confirm overage is NOT in the invoice + const overageIfCharged = calculateExpectedInvoiceAmount({ + items: pro.items, + usage: [{ featureId: TestFeature.Messages, value: 500 }], + options: { includeFixed: false, onlyArrear: true }, + }); + expect(overageIfCharged).toBe(40); + + // The final invoice should be much less than the overage amount + // (in fact, it should be negative since it's just a refund) + expect(finalInvoice?.total).toBeLessThan(overageIfCharged); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Track → cancel end of cycle → advance (customer-level) // ═══════════════════════════════════════════════════════════════════════════════ /** @@ -47,7 +179,7 @@ import chalk from "chalk"; * * Migrated from: cancel2.test.ts */ -test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel end of cycle → advance (customer)")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel end of cycle → advance")}`, async () => { const customerId = "cancel-cons-eoc-cus"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -138,7 +270,7 @@ test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel end o }); // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 2: Track → cancel immediately (customer-level) - no double charge +// TEST 3: Track → cancel immediately (customer-level) - no overage charge // ═══════════════════════════════════════════════════════════════════════════════ /** @@ -149,10 +281,10 @@ test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel end o * * Expected Result: * - Initial invoice: $20 (pro base price) - * - Final invoice: $20 (overage $40 - base refund $20) + * - Final invoice: -$20 (base refund only, no overage - arrear overages not charged on cancel) * - Product removed immediately */ -test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel immediately (customer) - no double charge")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel immediately - no overage charge")}`, async () => { const customerId = "cancel-cons-imm-cus"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -186,6 +318,7 @@ test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel immed }); // Track 500 messages (100 included, 400 overage) + // Note: This overage will NOT be charged when canceling immediately await autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, @@ -200,8 +333,8 @@ test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel immed }; const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); - // Final invoice = overage ($40) - base refund ($20) = $20 - expect(preview.total).toBe(20); + // Final invoice = base refund only (-$20), no overage charged on cancel + expect(preview.total).toBe(-20); // Execute cancel await autumnV1.subscriptions.update(cancelParams); @@ -222,10 +355,10 @@ test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel immed env: ctx.env, }); - // Should have 2 invoices: initial ($20) + final (overage - refund) + // Should have 2 invoices: initial ($20) + final (refund -$20) expect(customerAfterCancel.invoices?.length).toBe(2); - // Verify final invoice matches preview + // Verify final invoice matches preview (refund only) expectCustomerInvoiceCorrect({ customer: customerAfterCancel, count: 2, @@ -234,671 +367,7 @@ test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel immed }); // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 3: Track → cancel end of cycle → advance (entity) -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Entity has Pro with consumable messages (100 included, $0.10/unit overage) - * - Track 500 messages on entity (400 overage) - * - Cancel entity's product end of cycle - * - Advance to next invoice - * - * Expected Result: - * - Initial invoice: $20 (pro base price) - * - Final invoice: $40 (400 overage * $0.10) - * - Entity product removed after cycle ends - * - * Migrated from: entity3.test.ts - */ -test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel end of cycle → advance (entity)")}`, async () => { - const customerId = "cancel-cons-eoc-ent"; - - const consumableItem = items.consumableMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [consumableItem], - }); - - const { autumnV1, ctx, testClockId, entities } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - s.entities({ count: 1, featureId: TestFeature.Users }), - ], - actions: [s.attach({ productId: pro.id, entityIndex: 0 })], - }); - - const entityId = entities[0].id; - - // Verify pro is active on entity - const entity = await autumnV1.entities.get(customerId, entityId); - await expectProductActive({ - customer: entity, - productId: pro.id, - }); - - // Track 500 messages on entity (100 included, 400 overage) - await autumnV1.track({ - customer_id: customerId, - entity_id: entityId, - feature_id: TestFeature.Messages, - value: 500, - }); - - // Cancel entity's product end of cycle - await autumnV1.subscriptions.update({ - customer_id: customerId, - entity_id: entityId, - product_id: pro.id, - cancel: "end_of_cycle", - }); - - // Verify pro is canceling on entity - const entityAfterCancel = await autumnV1.entities.get(customerId, entityId); - await expectProductCanceling({ - customer: entityAfterCancel, - productId: pro.id, - }); - - // Advance to next invoice - await advanceToNextInvoice({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - }); - - // Verify final state - const entityAfterAdvance = await autumnV1.entities.get(customerId, entityId); - - // Product should be removed from entity - await expectProductNotPresent({ - customer: entityAfterAdvance, - productId: pro.id, - }); - - // Check customer invoices (invoices are at customer level) - const customerAfterAdvance = - await autumnV1.customers.get(customerId); - - // Should have 2 invoices: initial ($20) + final overage ($40) - expect(customerAfterAdvance.invoices?.length).toBe(2); - - // Final invoice should be overage: 400 * $0.10 = $40 - expect(customerAfterAdvance.invoices?.[0].total).toBe(40); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 4: Track → cancel immediately (entity) -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Entity has Pro with consumable messages (100 included, $0.10/unit overage) - * - Track 500 messages on entity (400 overage) - * - Cancel entity's product immediately - * - * Expected Result: - * - Initial invoice: $20 (pro base price) - * - Final invoice with overage charges - * - Entity product removed immediately - */ -test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel immediately (entity)")}`, async () => { - const customerId = "cancel-cons-imm-ent"; - - const consumableItem = items.consumableMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [consumableItem], - }); - - const { autumnV1, ctx, entities } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - s.entities({ count: 1, featureId: TestFeature.Users }), - ], - actions: [s.attach({ productId: pro.id, entityIndex: 0 })], - }); - - const entityId = entities[0].id; - - // Verify pro is active on entity - const entity = await autumnV1.entities.get(customerId, entityId); - await expectProductActive({ - customer: entity, - productId: pro.id, - }); - - // Track 500 messages on entity (100 included, 400 overage) - await autumnV1.track({ - customer_id: customerId, - entity_id: entityId, - feature_id: TestFeature.Messages, - value: 500, - }); - - // Preview cancel immediately - const cancelParams = { - customer_id: customerId, - entity_id: entityId, - product_id: pro.id, - cancel: "immediately" as const, - }; - const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); - - // Final invoice = overage ($40) - base refund ($20) = $20 - expect(preview.total).toBe(20); - - // Execute cancel - await autumnV1.subscriptions.update(cancelParams); - - // Verify pro is removed from entity - const entityAfterCancel = await autumnV1.entities.get(customerId, entityId); - await expectProductNotPresent({ - customer: entityAfterCancel, - productId: pro.id, - }); - - // Verify no Stripe subscription exists - await expectNoStripeSubscription({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); - - // Check customer invoices - const customerAfterCancel = - await autumnV1.customers.get(customerId); - - // Should have 2 invoices: initial ($20) + final (overage - refund) - expect(customerAfterCancel.invoices?.length).toBe(2); - - // Verify final invoice matches preview - expectCustomerInvoiceCorrect({ - customer: customerAfterCancel, - count: 2, - latestTotal: preview.total, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 5: Two entities, both overage, cancel end of cycle → advance -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Entity 1 and Entity 2 both have Pro with consumable messages - * - Track usage into overage on both entities - * - Cancel both end of cycle - * - Advance to next invoice - * - * Expected Result: - * - Both entities' overage billed in final invoices - * - Both products removed after cycle ends - */ -test.concurrent(`${chalk.yellowBright("cancel consumable: two entities, both overage, cancel end of cycle")}`, async () => { - const customerId = "cancel-cons-2ent-eoc"; - - const consumableItem = items.consumableMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [consumableItem], - }); - - const { autumnV1, ctx, testClockId, 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, entityIndex: 0 }), - s.attach({ productId: pro.id, entityIndex: 1, timeout: 3000 }), - ], - }); - - const entity1Id = entities[0].id; - const entity2Id = entities[1].id; - - // Verify pro is active on both entities - const entity1 = await autumnV1.entities.get(customerId, entity1Id); - const entity2 = await autumnV1.entities.get(customerId, entity2Id); - await expectProductActive({ customer: entity1, productId: pro.id }); - await expectProductActive({ customer: entity2, productId: pro.id }); - - // Verify initial invoices: 2 invoices, $20 each for entity attaches - const customerAfterAttach = - await autumnV1.customers.get(customerId); - expectCustomerInvoiceCorrect({ - customer: customerAfterAttach, - count: 2, - latestTotal: 20, - }); - - // Track usage on entity 1: 300 messages (200 overage = $20) - await autumnV1.track({ - customer_id: customerId, - entity_id: entity1Id, - feature_id: TestFeature.Messages, - value: 300, - }); - - // Track usage on entity 2: 600 messages (500 overage = $50) - await autumnV1.track({ - customer_id: customerId, - entity_id: entity2Id, - feature_id: TestFeature.Messages, - value: 600, - }); - - // Cancel both entities end of cycle - await autumnV1.subscriptions.update({ - customer_id: customerId, - entity_id: entity1Id, - product_id: pro.id, - cancel: "end_of_cycle", - }); - - await autumnV1.subscriptions.update({ - customer_id: customerId, - entity_id: entity2Id, - product_id: pro.id, - cancel: "end_of_cycle", - }); - - // Verify both are canceling - const entity1AfterCancel = await autumnV1.entities.get(customerId, entity1Id); - const entity2AfterCancel = await autumnV1.entities.get(customerId, entity2Id); - await expectProductCanceling({ - customer: entity1AfterCancel, - productId: pro.id, - }); - await expectProductCanceling({ - customer: entity2AfterCancel, - productId: pro.id, - }); - - // Advance to next invoice - await advanceToNextInvoice({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - }); - - // Verify both products removed - const entity1Final = await autumnV1.entities.get(customerId, entity1Id); - const entity2Final = await autumnV1.entities.get(customerId, entity2Id); - await expectProductNotPresent({ customer: entity1Final, productId: pro.id }); - await expectProductNotPresent({ customer: entity2Final, productId: pro.id }); - - // Calculate expected overage amounts - // Entity 1: 300 messages - 100 included = 200 overage * $0.10 = $20 - // Entity 2: 600 messages - 100 included = 500 overage * $0.10 = $50 - const entity1Overage = calculateExpectedInvoiceAmount({ - items: pro.items, - usage: [{ featureId: TestFeature.Messages, value: 300 }], - options: { includeFixed: false, onlyArrear: true }, - }); - const entity2Overage = calculateExpectedInvoiceAmount({ - items: pro.items, - usage: [{ featureId: TestFeature.Messages, value: 600 }], - options: { includeFixed: false, onlyArrear: true }, - }); - expect(entity1Overage).toBe(20); - expect(entity2Overage).toBe(50); - - const totalOverage = entity1Overage + entity2Overage; - expect(totalOverage).toBe(70); - - // Check customer invoices - const customerFinal = await autumnV1.customers.get(customerId); - - // Should have 3 invoices: - // - 2 initial invoices ($20 each for entity attaches) - // - 1 final invoice ($70 for combined overage from both entities) - expectCustomerInvoiceCorrect({ - customer: customerFinal, - count: 3, - latestTotal: totalOverage, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 6: Two entities, cancel one end of cycle, keep one active -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Entity 1 and Entity 2 both have Pro with consumable messages - * - Track usage into overage on both entities - * - Cancel only Entity 1 end of cycle - * - Keep Entity 2 active - * - Advance to next invoice - * - * Expected Result: - * - Entity 1's overage billed, product removed - * - Entity 2 continues with subscription, renews normally - */ -test.concurrent(`${chalk.yellowBright("cancel consumable: two entities, cancel one end of cycle, keep one active")}`, async () => { - const customerId = "cancel-cons-2ent-1eoc"; - - const consumableItem = items.consumableMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [consumableItem], - }); - - const { autumnV1, ctx, testClockId, 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, entityIndex: 0 }), - s.attach({ productId: pro.id, entityIndex: 1, timeout: 3000 }), - ], - }); - - const entity1Id = entities[0].id; - const entity2Id = entities[1].id; - - // Verify initial invoices: 2 invoices, $20 each for entity attaches - const customerAfterAttach = - await autumnV1.customers.get(customerId); - expectCustomerInvoiceCorrect({ - customer: customerAfterAttach, - count: 2, - latestTotal: 20, - }); - - // Track usage on entity 1: 400 messages (300 overage = $30) - await autumnV1.track({ - customer_id: customerId, - entity_id: entity1Id, - feature_id: TestFeature.Messages, - value: 400, - }); - - // Track usage on entity 2: 200 messages (100 overage = $10) - await autumnV1.track({ - customer_id: customerId, - entity_id: entity2Id, - feature_id: TestFeature.Messages, - value: 200, - }); - - // Cancel only entity 1 end of cycle - await autumnV1.subscriptions.update({ - customer_id: customerId, - entity_id: entity1Id, - product_id: pro.id, - cancel: "end_of_cycle", - }); - - // Verify entity 1 is canceling, entity 2 is still active - const entity1AfterCancel = await autumnV1.entities.get(customerId, entity1Id); - const entity2AfterCancel = await autumnV1.entities.get(customerId, entity2Id); - await expectProductCanceling({ - customer: entity1AfterCancel, - productId: pro.id, - }); - await expectProductActive({ - customer: entity2AfterCancel, - productId: pro.id, - }); - - // Advance to next invoice - await advanceToNextInvoice({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - withPause: true, - }); - - // Verify entity 1 product removed, entity 2 still active - const entity1Final = await autumnV1.entities.get(customerId, entity1Id); - const entity2Final = await autumnV1.entities.get(customerId, entity2Id); - await expectProductNotPresent({ customer: entity1Final, productId: pro.id }); - await expectProductActive({ customer: entity2Final, productId: pro.id }); - - // Entity 2's balance should be reset (new cycle) - expect(entity2Final.features[TestFeature.Messages].balance).toBe(100); - - // Calculate expected amounts - // Entity 1: 400 messages - 100 included = 300 overage * $0.10 = $30 - // Entity 2: 200 messages - 100 included = 100 overage * $0.10 = $10 - // Entity 2 also renews: $20 base price - const entity1Overage = calculateExpectedInvoiceAmount({ - items: pro.items, - usage: [{ featureId: TestFeature.Messages, value: 400 }], - options: { includeFixed: false, onlyArrear: true }, - }); - const entity2Overage = calculateExpectedInvoiceAmount({ - items: pro.items, - usage: [{ featureId: TestFeature.Messages, value: 200 }], - options: { includeFixed: false, onlyArrear: true }, - }); - expect(entity1Overage).toBe(30); - expect(entity2Overage).toBe(10); - - // Check customer invoices - const customerFinal = await autumnV1.customers.get(customerId); - - // Should have 4 invoices: - // - 2 initial invoices ($20 each for entity attaches) - // - 1 final invoice for entity 1 overage ($30) + entity 2 overage ($10) + entity 2 renewal ($20) = $60 - expectCustomerInvoiceCorrect({ - customer: customerFinal, - count: 3, - latestTotal: entity1Overage + entity2Overage + 20, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 7: Two entities, cancel one immediately, keep one active -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Entity 1 and Entity 2 both have Pro with consumable messages - * - Track usage into overage on both entities - * - Cancel only Entity 1 immediately - * - Keep Entity 2 active - * - * Expected Result: - * - Entity 1's final invoice created, product removed immediately - * - Entity 2 continues unaffected - */ -test.concurrent(`${chalk.yellowBright("cancel consumable: two entities, cancel one immediately, keep one active")}`, async () => { - const customerId = "cancel-cons-2ent-1imm"; - - const consumableItem = items.consumableMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [consumableItem], - }); - - const { autumnV1, ctx, 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, entityIndex: 0 }), - s.attach({ productId: pro.id, entityIndex: 1 }), - ], - }); - - const entity1Id = entities[0].id; - const entity2Id = entities[1].id; - - // Verify initial invoices: 2 invoices, $20 each for entity attaches - const customerAfterAttach = - await autumnV1.customers.get(customerId); - expectCustomerInvoiceCorrect({ - customer: customerAfterAttach, - count: 2, - latestTotal: 20, - }); - - // Track usage on entity 1: 500 messages (400 overage = $40) - await autumnV1.track({ - customer_id: customerId, - entity_id: entity1Id, - feature_id: TestFeature.Messages, - value: 500, - }); - - // Track usage on entity 2: 150 messages (50 overage = $5) - await autumnV1.track({ - customer_id: customerId, - entity_id: entity2Id, - feature_id: TestFeature.Messages, - value: 150, - }); - - // Cancel only entity 1 immediately - await autumnV1.subscriptions.update({ - customer_id: customerId, - entity_id: entity1Id, - product_id: pro.id, - cancel: "immediately", - }); - - // Verify entity 1 product removed immediately, entity 2 still active - const entity1AfterCancel = await autumnV1.entities.get(customerId, entity1Id); - const entity2AfterCancel = await autumnV1.entities.get(customerId, entity2Id); - await expectProductNotPresent({ - customer: entity1AfterCancel, - productId: pro.id, - }); - await expectProductActive({ - customer: entity2AfterCancel, - productId: pro.id, - }); - - // Calculate expected overage for entity 1 - // Entity 1: 500 messages - 100 included = 400 overage * $0.10 = $40 - const entity1Overage = calculateExpectedInvoiceAmount({ - items: pro.items, - usage: [{ featureId: TestFeature.Messages, value: 500 }], - options: { includeFixed: false, onlyArrear: true }, - }); - expect(entity1Overage).toBe(40); - - // Check customer invoices - const customerAfterCancel = - await autumnV1.customers.get(customerId); - - // Should have 3 invoices: - // - 2 initial invoices ($20 each for entity attaches) - // - 1 final invoice for entity 1 overage ($40) - expectCustomerInvoiceCorrect({ - customer: customerAfterCancel, - count: 3, - latestTotal: entity1Overage, - }); - - // Verify entity 2 has the product and subscription exists - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - subCount: 1, // Entity 2's subscription should still exist - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 8: Entity within included usage, cancel immediately (no overage) -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Entity has Pro with consumable messages (100 included) - * - Track 50 messages (within included usage, no overage) - * - Cancel immediately - * - * Expected Result: - * - No overage invoice (usage within included) - * - Only refund invoice for unused time (if applicable) - * - Product removed immediately - */ -test.concurrent(`${chalk.yellowBright("cancel consumable: entity within included usage, cancel immediately")}`, async () => { - const customerId = "cancel-cons-no-overage"; - - const consumableItem = items.consumableMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [consumableItem], - }); - - const { autumnV1, entities } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - s.entities({ count: 1, featureId: TestFeature.Users }), - ], - actions: [s.attach({ productId: pro.id, entityIndex: 0 })], - }); - - const entityId = entities[0].id; - - // Verify pro is active on entity - const entity = await autumnV1.entities.get(customerId, entityId); - await expectProductActive({ - customer: entity, - productId: pro.id, - }); - - // Track 50 messages (within 100 included, no overage) - await autumnV1.track({ - customer_id: customerId, - entity_id: entityId, - feature_id: TestFeature.Messages, - value: 50, - }); - - // Verify balance is correct (100 - 50 = 50) - const entityAfterTrack = await autumnV1.entities.get(customerId, entityId); - expect(entityAfterTrack.features[TestFeature.Messages].balance).toBe(50); - - // Cancel immediately - await autumnV1.subscriptions.update({ - customer_id: customerId, - entity_id: entityId, - product_id: pro.id, - cancel: "immediately", - }); - - // Verify product removed - const entityAfterCancel = await autumnV1.entities.get(customerId, entityId); - await expectProductNotPresent({ - customer: entityAfterCancel, - productId: pro.id, - }); - - // Check invoices - should have initial ($20) and possibly refund, but NO overage charge - const customerAfterCancel = - await autumnV1.customers.get(customerId); - - // Verify the final invoice (if exists) is a refund (negative or zero), not an overage charge - if (customerAfterCancel.invoices && customerAfterCancel.invoices.length > 1) { - const finalInvoice = customerAfterCancel.invoices[0]; - // Final invoice should be refund (negative) or small, not a large overage charge - expect(finalInvoice.total).toBeLessThanOrEqual(0); - } -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 9: Track → cancel immediately with failed payment +// TEST 4: Track → cancel immediately with failed payment // ═══════════════════════════════════════════════════════════════════════════════ /** @@ -909,11 +378,11 @@ test.concurrent(`${chalk.yellowBright("cancel consumable: entity within included * - Cancel immediately * * Expected Result: - * - Final invoice created with overage charges - * - Invoice shows as unpaid/failed + * - Final invoice: refund only (-$20), no overage charged on cancel + * - Since it's a refund (credit), no payment attempt needed * - Product still removed */ -test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel immediately with failed payment")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel immediately with failed payment - refund only")}`, async () => { const customerId = "cancel-cons-fail-pay"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -942,7 +411,8 @@ test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel immed productId: pro.id, }); - // Track 1000 messages (900 overage = $90) + // Track 1000 messages (900 overage) + // Note: This overage will NOT be charged when canceling immediately await autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, @@ -956,7 +426,7 @@ test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel immed cancel: "immediately", }); - // Verify product is removed despite payment failure + // Verify product is removed const customerAfterCancel = await autumnV1.customers.get(customerId); await expectProductNotPresent({ @@ -964,13 +434,13 @@ test.concurrent(`${chalk.yellowBright("cancel consumable: track → cancel immed productId: pro.id, }); - // Verify invoice was created (even if payment failed) + // Verify invoice was created expect(customerAfterCancel.invoices?.length).toBeGreaterThanOrEqual(2); // Find the final invoice (most recent) const finalInvoice = customerAfterCancel.invoices?.[0]; - // Final invoice should exist and contain overage charges - // Status should be 'open' or 'uncollectible' due to failed payment (not 'paid') - expect(finalInvoice?.status).not.toBe("paid"); + // Final invoice should be a refund (-$20), no overage charged on cancel + // Refunds/credits don't require payment, so status should be 'paid' (applied as credit) + expect(finalInvoice?.total).toBe(-20); }); diff --git a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts index 13925941e..27f8c69bf 100644 --- a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts +++ b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts @@ -90,6 +90,11 @@ export const isCustomerProductCanceling = (cp?: FullCusProduct) => { return notNullish(cp.canceled_at); }; +export const isCustomerProductExpired = (cp?: FullCusProduct) => { + if (!cp) return false; + return cp.status === CusProductStatus.Expired; +}; + /** * Checks if a canceling customer product has reached its end time. * Uses an optional tolerance to handle timing differences between Stripe and webhook arrival. diff --git a/vite/src/App.tsx b/vite/src/App.tsx index 4e7d29bec..4b7c0fdf0 100644 --- a/vite/src/App.tsx +++ b/vite/src/App.tsx @@ -35,7 +35,7 @@ export default function App() { const { data } = useSession(); useEffect(() => { - if (data) { + if (data?.user) { identifyUser({ email: data.user.email, name: data.user.name, diff --git a/vite/src/views/admin/hooks/useAdmin.tsx b/vite/src/views/admin/hooks/useAdmin.tsx index 595b1a9c0..18a6e3855 100644 --- a/vite/src/views/admin/hooks/useAdmin.tsx +++ b/vite/src/views/admin/hooks/useAdmin.tsx @@ -9,7 +9,7 @@ export const useAdmin = () => { useEffect(() => { if ( data?.user?.role === "admin" || - notNullish(data?.session.impersonatedBy) + notNullish(data?.session?.impersonatedBy) ) { setIsAdmin(true); } else {