fix cancel tests

This commit is contained in:
John Yeo
2026-01-21 14:54:46 +00:00
parent 9ad37c379e
commit 53d7a5b896
5 changed files with 854 additions and 691 deletions

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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);
}
});

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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);
});

View File

@@ -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.

View File

@@ -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,

View File

@@ -9,7 +9,7 @@ export const useAdmin = () => {
useEffect(() => {
if (
data?.user?.role === "admin" ||
notNullish(data?.session.impersonatedBy)
notNullish(data?.session?.impersonatedBy)
) {
setIsAdmin(true);
} else {