diff --git a/server/src/internal/balances/utils/allocatedInvoice/compute/computeUpdateCustomerEntitlementPlan.ts b/server/src/internal/balances/utils/allocatedInvoice/compute/computeUpdateCustomerEntitlementPlan.ts index c32a67d31..52be6ed1a 100644 --- a/server/src/internal/balances/utils/allocatedInvoice/compute/computeUpdateCustomerEntitlementPlan.ts +++ b/server/src/internal/balances/utils/allocatedInvoice/compute/computeUpdateCustomerEntitlementPlan.ts @@ -47,7 +47,10 @@ export const computeUpdateCustomerEntitlementPlan = ({ // Downgrade case if (previousOverage <= 0) { // Just return - return undefined; + return { + customerEntitlement, + balanceChange: 0, + }; } else { // Plan for downgrade const customerPrice = cusEntToCusPrice({ diff --git a/server/tests/balances/track/paid-allocated/track-paid-allocated4.test.ts b/server/tests/balances/track/paid-allocated/track-paid-allocated4.test.ts deleted file mode 100644 index 8da41f47a..000000000 --- a/server/tests/balances/track/paid-allocated/track-paid-allocated4.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - ApiVersion, - LegacyVersion, - OnDecrease, - OnIncrease, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectSubQuantityCorrect } from "@tests/utils/expectUtils/expectContUseUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { getV2Balance } from "../../testBalanceUtils"; - -const userItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 1, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.None, - }, -}); - -export const pro = constructProduct({ - items: [userItem], - type: "pro", -}); - -const testCase = "track-paid-allocated4"; - -describe(`${chalk.yellowBright(`${testCase}: Testing track usage for cont use (without overage)`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - }); - - let usage = 0; - test("should attach pro", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - }); - - test("should track +1 and have no new invoice", async () => { - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Users, - value: 1, - }); - - usage += 1; - - await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - }); - - const customer = await autumn.customers.get(customerId); - const invoices = customer.invoices; - expect(invoices.length).toBe(1); - }); - - test("should track -1 and have no new invoice", async () => { - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Users, - value: -1, - }); - - usage -= 1; - - await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - }); - - // Verify balance values reflect the replaceable (unused) logic - const v2Balance = await getV2Balance({ - customerId, - featureId: TestFeature.Users, - }); - - expect(v2Balance).toMatchObject({ - granted_balance: 1, - purchased_balance: 0, - current_balance: 1, - usage: 0, - }); - }); -}); diff --git a/server/tests/balances/track/paid-allocated/track-paid-allocated5.test.ts b/server/tests/balances/track/paid-allocated/track-paid-allocated5.test.ts deleted file mode 100644 index c479ac345..000000000 --- a/server/tests/balances/track/paid-allocated/track-paid-allocated5.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion, OnDecrease, OnIncrease } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { - expectSubQuantityCorrect, - expectUpcomingItemsCorrect, -} from "@tests/utils/expectUtils/expectContUseUtils.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { getV2Balance } from "../../testBalanceUtils"; - -const userItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 1, - config: { - on_increase: OnIncrease.ProrateNextCycle, - on_decrease: OnDecrease.ProrateNextCycle, - }, -}); - -export const pro = constructProduct({ - items: [userItem], - type: "pro", -}); - -const testCase = "track-paid-allocated5"; - -describe(`${chalk.yellowBright(`${testCase}: Testing track usage for cont use, prorate next cycle`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let curUnix = Date.now(); - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId1!; - }); - - let usage = 0; - test("should attach pro", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - }); - - test("should create track +3 usage and have correct invoice", async () => { - curUnix = await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addWeeks(new Date(), 2).getTime(), - waitForSeconds: 30, - }); - - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Users, - value: 3, - }); - - usage += 3; - - const { stripeSubs, fullCus } = await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - }); - - await expectUpcomingItemsCorrect({ - stripeCli: ctx.stripeCli, - fullCus, - stripeSubs, - curUnix, - expectedNumItems: 1, - unitPrice: userItem.price!, - quantity: 2, - }); - - const customer = await autumn.customers.get(customerId); - const invoices = customer.invoices; - expect(invoices.length).toBe(1); - - const v2Balance = await getV2Balance({ - customerId, - featureId: TestFeature.Users, - }); - - expect(v2Balance).toMatchObject({ - granted_balance: 1, - purchased_balance: 2, - current_balance: 0, - usage: 3, - }); - }); - - test("should track -1 and have no new invoice", async () => { - curUnix = await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 1).getTime(), - waitForSeconds: 30, - }); - - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Users, - value: -1, - }); - - usage -= 1; - - const { stripeSubs, fullCus } = await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - }); - - await expectUpcomingItemsCorrect({ - stripeCli: ctx.stripeCli, - fullCus, - stripeSubs, - unitPrice: userItem.price!, - curUnix, - expectedNumItems: 2, - quantity: -1, - }); - - const customer = await autumn.customers.get(customerId); - const invoices = customer.invoices; - expect(invoices.length).toBe(1); - - const v2Balance = await getV2Balance({ - customerId, - featureId: TestFeature.Users, - }); - - expect(v2Balance).toMatchObject({ - granted_balance: 1, - purchased_balance: 1, - current_balance: 0, - usage: 2, - }); - }); - - test("should track -1 and have no new invoice", async () => { - const quantity = -1; - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Users, - value: quantity, - }); - - usage += quantity; - - const { stripeSubs, cusProduct, fullCus } = await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - }); - - await expectUpcomingItemsCorrect({ - stripeCli: ctx.stripeCli, - fullCus, - stripeSubs, - unitPrice: userItem.price!, - curUnix, - expectedNumItems: 3, - quantity, - }); - - const customer = await autumn.customers.get(customerId); - const invoices = customer.invoices; - expect(invoices.length).toBe(1); - - const v2Balance = await getV2Balance({ - customerId, - featureId: TestFeature.Users, - }); - - expect(v2Balance).toMatchObject({ - granted_balance: 1, - purchased_balance: 0, - current_balance: 0, - usage: 1, - }); - }); -}); diff --git a/server/tests/integration/balances/legacy/legacy-set-usage.test.ts b/server/tests/integration/balances/legacy/legacy-set-usage.test.ts index 23696c725..6c5d202bc 100644 --- a/server/tests/integration/balances/legacy/legacy-set-usage.test.ts +++ b/server/tests/integration/balances/legacy/legacy-set-usage.test.ts @@ -5,12 +5,10 @@ import { OnIncrease, ProductItemFeatureType, } from "@autumn/shared"; +import { calculateProratedDiff } from "@tests/integration/billing/utils/proration/calculateProratedDiff.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; -import { - expectSubQuantityCorrect, - expectUpcomingItemsCorrect, -} from "@tests/utils/expectUtils/expectContUseUtils.js"; +import { expectSubQuantityCorrect } from "@tests/utils/expectUtils/expectContUseUtils.js"; import { getSubsFromCusId } from "@tests/utils/expectUtils/expectSubUtils.js"; import { timeout } from "@tests/utils/genUtils.js"; import { advanceTestClock } from "@tests/utils/stripeUtils.js"; @@ -211,28 +209,36 @@ test.concurrent(`${chalk.yellowBright("legacy-set-usage2: ProrateNextCycle sub q await timeout(15000); - let usage = 3; + const usage1 = 3; - const { stripeSubs, fullCus } = await expectSubQuantityCorrect({ + const { stripeSubs } = await expectSubQuantityCorrect({ stripeCli: ctx.stripeCli, productId: pro.id, db: ctx.db, org: ctx.org, env: ctx.env, customerId, - usage, + usage: usage1, }); - await expectUpcomingItemsCorrect({ - stripeCli: ctx.stripeCli, - fullCus, - stripeSubs, - curUnix, - expectedNumItems: 1, - unitPrice: userItem.price!, - quantity: 2, // 3 usage - 1 included = 2 overage + const stripeCustomerId = stripeSubs[0].customer as string; + + // Step 1: overage went 0 → 2. Credit for $0 old is filtered; 1 deferred invoice item created. + const proratedCharge1 = await calculateProratedDiff({ + customerId, + advancedTo: curUnix, + oldAmount: 0, + newAmount: 2 * userItem.price!, }); + const items1 = await ctx.stripeCli.invoiceItems.list({ + customer: stripeCustomerId, + }); + expect(items1.data.length).toBe(1); + expect( + Math.abs(items1.data[0].amount - Math.round(proratedCharge1 * 100)), + ).toBeLessThanOrEqual(1); + const customer1 = await autumnV1.customers.get(customerId); expect(customer1.invoices!.length).toBe(1); @@ -250,7 +256,7 @@ test.concurrent(`${chalk.yellowBright("legacy-set-usage2: ProrateNextCycle sub q value: 2, }); - const result2 = await expectSubQuantityCorrect({ + await expectSubQuantityCorrect({ stripeCli: ctx.stripeCli, productId: pro.id, db: ctx.db, @@ -260,16 +266,25 @@ test.concurrent(`${chalk.yellowBright("legacy-set-usage2: ProrateNextCycle sub q usage: 2, }); - await expectUpcomingItemsCorrect({ - stripeCli: ctx.stripeCli, - fullCus: result2.fullCus, - stripeSubs: result2.stripeSubs, - unitPrice: userItem.price!, - curUnix, - expectedNumItems: 2, - quantity: -1, // decrease by 1 seat + // Step 2: overage went 2 → 1. Two deferred items: credit for old (2 overage) + charge for new (1 overage). + // Net of the 2 newest items = prorated diff from 2×$50 → 1×$50. + const proratedDiff2 = await calculateProratedDiff({ + customerId, + advancedTo: curUnix, + oldAmount: 2 * userItem.price!, + newAmount: 1 * userItem.price!, }); + const items2 = await ctx.stripeCli.invoiceItems.list({ + customer: stripeCustomerId, + }); + expect(items2.data.length).toBe(3); + + const netStep2Cents = items2.data[0].amount + items2.data[1].amount; + expect( + Math.abs(netStep2Cents - Math.round(proratedDiff2 * 100)), + ).toBeLessThanOrEqual(1); + const customer2 = await autumnV1.customers.get(customerId); expect(customer2.invoices!.length).toBe(1); @@ -280,28 +295,35 @@ test.concurrent(`${chalk.yellowBright("legacy-set-usage2: ProrateNextCycle sub q value: 4, }); - usage = 4; - - const result3 = await expectSubQuantityCorrect({ + await expectSubQuantityCorrect({ stripeCli: ctx.stripeCli, productId: pro.id, db: ctx.db, org: ctx.org, env: ctx.env, customerId, - usage, + usage: 4, }); - await expectUpcomingItemsCorrect({ - stripeCli: ctx.stripeCli, - fullCus: result3.fullCus, - stripeSubs: result3.stripeSubs, - unitPrice: userItem.price!, - curUnix, - expectedNumItems: 3, - quantity: 2, // +2 seats from usage=2 to usage=4 + // Step 3: overage went 1 → 3. Two deferred items: credit for old (1 overage) + charge for new (3 overage). + // Net of the 2 newest items = prorated diff from 1×$50 → 3×$50. + const proratedDiff3 = await calculateProratedDiff({ + customerId, + advancedTo: curUnix, + oldAmount: 1 * userItem.price!, + newAmount: 3 * userItem.price!, }); + const items3 = await ctx.stripeCli.invoiceItems.list({ + customer: stripeCustomerId, + }); + expect(items3.data.length).toBe(5); + + const netStep3Cents = items3.data[0].amount + items3.data[1].amount; + expect( + Math.abs(netStep3Cents - Math.round(proratedDiff3 * 100)), + ).toBeLessThanOrEqual(1); + const customer3 = await autumnV1.customers.get(customerId); expect(customer3.invoices!.length).toBe(1); -}, 300_000); // Longer timeout for Stripe test clock operations +}); // Longer timeout for Stripe test clock operations diff --git a/server/tests/integration/balances/track/allocated-invoice/bill-immediate.test.ts b/server/tests/integration/balances/track/allocated-invoice/bill-immediate.test.ts index 160df22dd..b1c21de8f 100644 --- a/server/tests/integration/balances/track/allocated-invoice/bill-immediate.test.ts +++ b/server/tests/integration/balances/track/allocated-invoice/bill-immediate.test.ts @@ -75,6 +75,31 @@ test(`${chalk.yellowBright("bill-imm1: track within included usage creates no in await expectCustomerInvoiceCorrect({ customerId, count: 1 }); await expectStripeSubscriptionCorrect({ ctx, customerId }); + + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: -1, + }); + + expect(trackRes.balance).toMatchObject({ + granted_balance: 1, + purchased_balance: 0, + current_balance: 1, + usage: 0, + }); + + await expectFeatureCachedAndDb({ + autumn: autumnV1, + customerId, + featureId: TestFeature.Users, + balance: 1, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ customerId, count: 1 }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); }); // ═══════════════════════════════════════════════════════════════════ diff --git a/server/tests/integration/balances/track/allocated-invoice/prorate-next-cycle.test.ts b/server/tests/integration/balances/track/allocated-invoice/prorate-next-cycle.test.ts index 2c50669fc..54276dd21 100644 --- a/server/tests/integration/balances/track/allocated-invoice/prorate-next-cycle.test.ts +++ b/server/tests/integration/balances/track/allocated-invoice/prorate-next-cycle.test.ts @@ -10,8 +10,10 @@ import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/e import { expectFeatureCachedAndDb } from "@tests/integration/billing/utils/expectFeatureCachedAndDb.js"; import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect.js"; import { calculateProratedDiff } from "@tests/integration/billing/utils/proration/calculateProratedDiff.js"; +import { getStripeSubscription } from "@tests/integration/billing/utils/stripeSubscriptionUtils.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; @@ -193,3 +195,135 @@ test(`${chalk.yellowBright("prorate-nc2: mid-cycle decrease creates no immediate await expectStripeSubscriptionCorrect({ ctx, customerId }); }); + +// ═══════════════════════════════════════════════════════════════════ +// prorate-nc3: Track +3 then -1 — verify pending invoice items +// +// Checks that each track creates correct prorated pending invoice +// items on the Stripe subscription. A decrease creates two items +// (credit for old allocation + charge for new allocation). +// ═══════════════════════════════════════════════════════════════════ + +test(`${chalk.yellowBright("prorate-nc3: pending invoice items match prorated amounts after increase and decrease")}`, async () => { + const pro = products.pro({ id: "pro", items: [userItem] }); + + const { customerId, autumnV1, autumnV2, testClockId, advancedTo } = + await initScenario({ + customerId: "prorate-nc3", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.advanceTestClock({ weeks: 2 }), + ], + }); + + // ─── Step 1: Track +3 (1 included + 2 overage) ─── + + const trackRes1: TrackResponseV2 = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: 3, + }); + + expect(trackRes1.balance).toMatchObject({ + granted_balance: 1, + purchased_balance: 2, + current_balance: 0, + usage: 3, + }); + + await expectFeatureCachedAndDb({ + autumn: autumnV1, + customerId, + featureId: TestFeature.Users, + balance: -2, + usage: 3, + }); + + await expectCustomerInvoiceCorrect({ customerId, count: 1 }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + + const proratedOverage = await calculateProratedDiff({ + customerId, + advancedTo, + oldAmount: 0, + newAmount: 2 * PRICE_PER_SEAT, + }); + + const { stripeCli, stripeCustomerId } = await getStripeSubscription({ + customerId, + }); + + const itemsAfterIncrease = await stripeCli.invoiceItems.list({ + customer: stripeCustomerId, + }); + + expect(itemsAfterIncrease.data.length).toBe(1); + expect(itemsAfterIncrease.data[0].amount).toBe( + Math.round(proratedOverage * 100), + ); + + // ─── Step 2: Advance 1 week, then track -1 ─── + + const advancedTo2 = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(advancedTo), + numberOfWeeks: 1, + waitForSeconds: 30, + }); + + const trackRes2: TrackResponseV2 = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: -1, + }); + + expect(trackRes2.balance).toMatchObject({ + granted_balance: 1, + purchased_balance: 1, + current_balance: 0, + usage: 2, + }); + + await expectFeatureCachedAndDb({ + autumn: autumnV1, + customerId, + featureId: TestFeature.Users, + balance: -1, + usage: 2, + }); + + await expectCustomerInvoiceCorrect({ customerId, count: 1 }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + + const proratedDecrease = await calculateProratedDiff({ + customerId, + advancedTo: advancedTo2, + oldAmount: 2 * PRICE_PER_SEAT, + newAmount: 1 * PRICE_PER_SEAT, + }); + + const itemsAfterDecrease = await stripeCli.invoiceItems.list({ + customer: stripeCustomerId, + }); + + // 3 total: 1 from the increase + 2 from the decrease (credit + charge) + expect(itemsAfterDecrease.data.length).toBe(3); + + // Newest 2 items are from the decrease (Stripe returns newest first) + const decreaseItems = itemsAfterDecrease.data.slice(0, 2); + const hasCredit = decreaseItems.some((item) => item.amount < 0); + const hasCharge = decreaseItems.some((item) => item.amount > 0); + expect(hasCredit).toBe(true); + expect(hasCharge).toBe(true); + + const decreaseNetCents = decreaseItems.reduce( + (sum, item) => sum + item.amount, + 0, + ); + expect(decreaseNetCents).toBe(Math.round(proratedDecrease * 100)); +});