From 8de38b7601f8e14b43f726b6da9abe0ee2c56d2f Mon Sep 17 00:00:00 2001 From: John Yeo Date: Mon, 12 Jan 2026 12:15:19 +0000 Subject: [PATCH] minor cleanup --- .../stripe/setup/setupStripeBillingContext.ts | 2 +- .../discounts/addDiscountTagToDescription.ts | 13 + .../applyAmountOffDiscountToLineItems.ts | 14 +- .../applyPercentOffDiscountToLineItems.ts | 4 + .../scriptUtils/testUtils/initCustomerV3.ts | 1 + .../discounts/entity-discount.test.ts | 616 +++++++------- .../discounts/multiple-discounts.test.ts | 730 ++++++++-------- .../discounts/proration-discount.test.ts | 523 ++++++------ .../discounts/subscription-discounts.test.ts | 797 +++++++++--------- shared/utils/utils.ts | 3 +- 10 files changed, 1332 insertions(+), 1371 deletions(-) create mode 100644 server/src/internal/billing/v2/providers/stripe/utils/discounts/addDiscountTagToDescription.ts diff --git a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts index 99d83c070..4a44e89b1 100644 --- a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts +++ b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts @@ -1,9 +1,9 @@ import type { FullCusProduct, FullCustomer } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { fetchStripeCustomerForBilling } from "./fetchStripeCustomerForBilling"; -import { setupStripeDiscountsForBilling } from "./setupStripeDiscountsForBilling"; import { fetchStripeSubscriptionForBilling } from "./fetchStripeSubscriptionForBilling"; import { fetchStripeSubscriptionScheduleForBilling } from "./fetchStripeSubscriptionScheduleForBilling"; +import { setupStripeDiscountsForBilling } from "./setupStripeDiscountsForBilling"; export const setupStripeBillingContext = async ({ ctx, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/addDiscountTagToDescription.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/addDiscountTagToDescription.ts new file mode 100644 index 000000000..61810ad65 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/addDiscountTagToDescription.ts @@ -0,0 +1,13 @@ +const DISCOUNT_TAG = "[inc. discount]"; + +/** Suffixes "[inc. discount]" to description if not already present */ +export const addDiscountTagToDescription = ({ + description, +}: { + description: string; +}): string => { + if (description.includes(DISCOUNT_TAG)) { + return description; + } + return `${description} ${DISCOUNT_TAG}`; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyAmountOffDiscountToLineItems.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyAmountOffDiscountToLineItems.ts index 703f25026..45b2f2d08 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyAmountOffDiscountToLineItems.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyAmountOffDiscountToLineItems.ts @@ -3,8 +3,10 @@ import { type LineItemDiscount, type StripeDiscountWithCoupon, stripeToAtmnAmount, + sumValues, } from "@autumn/shared"; import { Decimal } from "decimal.js"; +import { addDiscountTagToDescription } from "./addDiscountTagToDescription"; import { discountAppliesToLineItem } from "./discountAppliesToLineItem"; /** @@ -33,8 +35,8 @@ export const applyAmountOffDiscountToLineItems = ({ // Filter to applicable CHARGE line items only // Discounts reduce what the customer pays, so only apply to charges - const applicableChargeItems = lineItems.filter( - (item) => discountAppliesToLineItem({ discount, lineItem: item }), + const applicableChargeItems = lineItems.filter((item) => + discountAppliesToLineItem({ discount, lineItem: item }), ); if (applicableChargeItems.length === 0) return lineItems; @@ -43,9 +45,8 @@ export const applyAmountOffDiscountToLineItems = ({ const discountMap = new Map(); // Distribute discount proportionally across charge items - const total = applicableChargeItems.reduce( - (sum, item) => sum + Math.abs(item.amount), - 0, + const total = sumValues( + applicableChargeItems.map((item) => Math.abs(item.amount)), ); if (total === 0) return lineItems; @@ -80,6 +81,9 @@ export const applyAmountOffDiscountToLineItems = ({ return { ...item, + description: addDiscountTagToDescription({ + description: item.description, + }), discounts: [...existingDiscounts, newDiscount], finalAmount, }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyPercentOffDiscountToLineItems.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyPercentOffDiscountToLineItems.ts index 22cccdb49..592a5118d 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyPercentOffDiscountToLineItems.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyPercentOffDiscountToLineItems.ts @@ -4,6 +4,7 @@ import type { StripeDiscountWithCoupon, } from "@autumn/shared"; import { Decimal } from "decimal.js"; +import { addDiscountTagToDescription } from "./addDiscountTagToDescription"; import { discountAppliesToLineItem } from "./discountAppliesToLineItem"; /** @@ -60,6 +61,9 @@ export const applyPercentOffDiscountToLineItems = ({ return { ...item, + description: addDiscountTagToDescription({ + description: item.description, + }), discounts: [...existingDiscounts, newDiscount], finalAmount, }; diff --git a/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts index fc1b2159e..d93129b05 100644 --- a/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts +++ b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts @@ -49,6 +49,7 @@ export const initCustomerV3 = async ({ }); // 2. Create customer + try { await autumn.customers.delete(customerId); } catch (_error) {} diff --git a/server/tests/billing/update-subscription/discounts/entity-discount.test.ts b/server/tests/billing/update-subscription/discounts/entity-discount.test.ts index e51b07675..9d9895c42 100644 --- a/server/tests/billing/update-subscription/discounts/entity-discount.test.ts +++ b/server/tests/billing/update-subscription/discounts/entity-discount.test.ts @@ -14,364 +14,352 @@ import { products } from "@tests/utils/fixtures/products.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; import { - getStripeSubscription, - createPercentCoupon, applySubscriptionDiscount, + createPercentCoupon, + getStripeSubscription, } from "../../utils/discounts/discountTestUtils.js"; const billingUnits = 12; const pricePerUnit = 10; -test.concurrent( - `${chalk.yellowBright("entity: discount on single entity upgrade")}`, - async () => { - const customerId = "entity-single-upgrade"; +test.concurrent(`${chalk.yellowBright("entity: discount on single entity upgrade")}`, async () => { + const customerId = "entity-single-upgrade"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1, entities } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - s.attach({ - productId: product.id, - entityIndex: 0, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - s.attach({ - productId: product.id, - entityIndex: 1, - options: [ - { feature_id: TestFeature.Messages, quantity: 3 * billingUnits }, - ], - }), - ], - }); + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ + productId: product.id, + entityIndex: 0, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + s.attach({ + productId: product.id, + entityIndex: 1, + options: [ + { feature_id: TestFeature.Messages, quantity: 3 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - const coupon = await createPercentCoupon({ - stripeCli, - percentOff: 25, - }); + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 25, + }); - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: subscription.id, - couponIds: [coupon.id], - }); + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [coupon.id], + }); - // Preview upgrade for entity 0 only - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - entity_id: entities[0].id, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + // Preview upgrade for entity 0 only + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge with 25% off: $100 * 0.75 = $75 - // Total: -$50 + $75 = $25 - const refundAmount = -50; - const discountedCharge = Math.round(100 * 0.75); - const expectedAmount = refundAmount + discountedCharge; + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge with 25% off: $100 * 0.75 = $75 + // Total: -$50 + $75 = $25 + const refundAmount = -50; + const discountedCharge = Math.round(100 * 0.75); + const expectedAmount = refundAmount + discountedCharge; - expect(preview.total).toBe(expectedAmount); - }, -); + expect(preview.total).toBe(expectedAmount); +}); -test.concurrent( - `${chalk.yellowBright("entity: both entities share subscription discount")}`, - async () => { - const customerId = "entity-shared-discount"; +test.concurrent(`${chalk.yellowBright("entity: both entities share subscription discount")}`, async () => { + const customerId = "entity-shared-discount"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1, entities } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - s.attach({ - productId: product.id, - entityIndex: 0, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - s.attach({ - productId: product.id, - entityIndex: 1, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ + productId: product.id, + entityIndex: 0, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + s.attach({ + productId: product.id, + entityIndex: 1, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - const coupon = await createPercentCoupon({ - stripeCli, - percentOff: 20, - }); + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: subscription.id, - couponIds: [coupon.id], - }); + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [coupon.id], + }); - // Preview upgrade for entity 0 - const preview0 = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - entity_id: entities[0].id, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + // Preview upgrade for entity 0 + const preview0 = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Preview upgrade for entity 1 - const preview1 = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - entity_id: entities[1].id, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + // Preview upgrade for entity 1 + const preview1 = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + entity_id: entities[1].id, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge with 20% off: $100 * 0.8 = $80 - // Total: -$50 + $80 = $30 - const refundAmount = -50; - const discountedCharge = Math.round(100 * 0.8); - const expectedAmount = refundAmount + discountedCharge; + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge with 20% off: $100 * 0.8 = $80 + // Total: -$50 + $80 = $30 + const refundAmount = -50; + const discountedCharge = Math.round(100 * 0.8); + const expectedAmount = refundAmount + discountedCharge; - expect(preview0.total).toBe(expectedAmount); - expect(preview1.total).toBe(expectedAmount); - }, -); + expect(preview0.total).toBe(expectedAmount); + expect(preview1.total).toBe(expectedAmount); +}); -test.concurrent( - `${chalk.yellowBright("entity: upgrade one entity while other unchanged")}`, - async () => { - const customerId = "entity-partial-upgrade"; +test.concurrent(`${chalk.yellowBright("entity: upgrade one entity while other unchanged")}`, async () => { + const customerId = "entity-partial-upgrade"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const initialQuantity = 5 * billingUnits; + const initialQuantity = 5 * billingUnits; - const { autumnV1, entities } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - s.attach({ - productId: product.id, - entityIndex: 0, - options: [ - { feature_id: TestFeature.Messages, quantity: initialQuantity }, - ], - }), - s.attach({ - productId: product.id, - entityIndex: 1, - options: [ - { feature_id: TestFeature.Messages, quantity: initialQuantity }, - ], - }), - ], - }); + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ + productId: product.id, + entityIndex: 0, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantity }, + ], + }), + s.attach({ + productId: product.id, + entityIndex: 1, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantity }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - const coupon = await createPercentCoupon({ - stripeCli, - percentOff: 30, - }); + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 30, + }); - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: subscription.id, - couponIds: [coupon.id], - }); + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [coupon.id], + }); - // Upgrade entity 0 only - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - entity_id: entities[0].id, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + // Upgrade entity 0 only + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge with 30% off: $100 * 0.7 = $70 - // Total: -$50 + $70 = $20 - const refundAmount = -50; - const discountedCharge = Math.round(100 * 0.7); - const expectedAmount = refundAmount + discountedCharge; + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge with 30% off: $100 * 0.7 = $70 + // Total: -$50 + $70 = $20 + const refundAmount = -50; + const discountedCharge = Math.round(100 * 0.7); + const expectedAmount = refundAmount + discountedCharge; - expect(preview.total).toBe(expectedAmount); + expect(preview.total).toBe(expectedAmount); - // Execute the upgrade - await autumnV1.subscriptions.update({ - customer_id: customerId, - entity_id: entities[0].id, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + // Execute the upgrade + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Verify entity 1 still has original balance (unchanged) - const entity1 = await autumnV1.entities.get(customerId, entities[1].id); - const feature = entity1.features?.[TestFeature.Messages]; + // Verify entity 1 still has original balance (unchanged) + const entity1 = await autumnV1.entities.get(customerId, entities[1].id); + const feature = entity1.features?.[TestFeature.Messages]; - expect(feature?.balance).toBe(initialQuantity); - }, -); + expect(feature?.balance).toBe(initialQuantity); +}); -test.concurrent( - `${chalk.yellowBright("entity: discount on entity product switch")}`, - async () => { - const customerId = "entity-product-switch"; +test.concurrent(`${chalk.yellowBright("entity: discount on entity product switch")}`, async () => { + const customerId = "entity-product-switch"; - const basicProduct = products.base({ - id: "basic", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: 5, // $5 per unit - }), - ], - }); + const basicProduct = products.base({ + id: "basic", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: 5, // $5 per unit + }), + ], + }); - const proProduct = products.base({ - id: "pro", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, // $10 per unit - }), - ], - }); + const proProduct = products.base({ + id: "pro", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, // $10 per unit + }), + ], + }); - const { autumnV1, entities } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [basicProduct, proProduct] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - // Entity 0 on basic product - s.attach({ - productId: basicProduct.id, - entityIndex: 0, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - // Entity 1 on pro product - s.attach({ - productId: proProduct.id, - entityIndex: 1, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [basicProduct, proProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + // Entity 0 on basic product + s.attach({ + productId: basicProduct.id, + entityIndex: 0, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + // Entity 1 on pro product + s.attach({ + productId: proProduct.id, + entityIndex: 1, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - const coupon = await createPercentCoupon({ - stripeCli, - percentOff: 40, - }); + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 40, + }); - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: subscription.id, - couponIds: [coupon.id], - }); + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [coupon.id], + }); - // Preview upgrade on entity 1's pro product - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - entity_id: entities[1].id, - product_id: proProduct.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + // Preview upgrade on entity 1's pro product + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + entity_id: entities[1].id, + product_id: proProduct.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge with 40% off: $100 * 0.6 = $60 - // Total: -$50 + $60 = $10 - const refundAmount = -50; - const discountedCharge = Math.round(100 * 0.6); - const expectedAmount = refundAmount + discountedCharge; + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge with 40% off: $100 * 0.6 = $60 + // Total: -$50 + $60 = $10 + const refundAmount = -50; + const discountedCharge = Math.round(100 * 0.6); + const expectedAmount = refundAmount + discountedCharge; - expect(preview.total).toBe(expectedAmount); - }, -); + expect(preview.total).toBe(expectedAmount); +}); diff --git a/server/tests/billing/update-subscription/discounts/multiple-discounts.test.ts b/server/tests/billing/update-subscription/discounts/multiple-discounts.test.ts index 479a33a9d..239f05ad0 100644 --- a/server/tests/billing/update-subscription/discounts/multiple-discounts.test.ts +++ b/server/tests/billing/update-subscription/discounts/multiple-discounts.test.ts @@ -14,10 +14,10 @@ import { products } from "@tests/utils/fixtures/products.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; import { - getStripeSubscription, - createPercentCoupon, - createAmountCoupon, applySubscriptionDiscount, + createAmountCoupon, + createPercentCoupon, + getStripeSubscription, } from "../../utils/discounts/discountTestUtils.js"; const billingUnits = 12; @@ -27,433 +27,415 @@ const pricePerUnit = 10; // MIGRATED TESTS FROM subscription-discounts.test.ts // ============================================================================= -test.concurrent( - `${chalk.yellowBright("stacking: two percent-off discounts (multiplicative)")}`, - async () => { - const customerId = "stack-pct-pct"; +test.concurrent(`${chalk.yellowBright("stacking: two percent-off discounts (multiplicative)")}`, async () => { + const customerId = "stack-pct-pct"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - // Create two percent coupons: 20% and 10% - const coupon1 = await createPercentCoupon({ - stripeCli, - percentOff: 20, - }); + // Create two percent coupons: 20% and 10% + const coupon1 = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); - const coupon2 = await createPercentCoupon({ - stripeCli, - percentOff: 10, - }); + const coupon2 = await createPercentCoupon({ + stripeCli, + percentOff: 10, + }); - // Apply both discounts to subscription - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: subscription.id, - couponIds: [coupon1.id, coupon2.id], - }); + // Apply both discounts to subscription + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [coupon1.id, coupon2.id], + }); - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge: $100, 20% off = $80, 10% off = $72 - // Total: -$50 + $72 = $22 - const refundAmount = -50; - const afterFirstDiscount = Math.round(100 * 0.8); - const discountedCharge = Math.round(afterFirstDiscount * 0.9); - const expectedAmount = refundAmount + discountedCharge; + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge: $100, 20% off = $80, 10% off = $72 + // Total: -$50 + $72 = $22 + const refundAmount = -50; + const afterFirstDiscount = Math.round(100 * 0.8); + const discountedCharge = Math.round(afterFirstDiscount * 0.9); + const expectedAmount = refundAmount + discountedCharge; - expect(preview.total).toBe(expectedAmount); - }, -); + expect(preview.total).toBe(expectedAmount); +}); // ============================================================================= // NEW STACKING TESTS // ============================================================================= -test.concurrent( - `${chalk.yellowBright("stacking: percent then amount (percent applied first)")}`, - async () => { - const customerId = "stack-pct-amt"; +test.concurrent(`${chalk.yellowBright("stacking: percent then amount (percent applied first)")}`, async () => { + const customerId = "stack-pct-amt"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - // 30% off + $5 off - const percentCoupon = await createPercentCoupon({ - stripeCli, - percentOff: 30, - }); + // 30% off + $5 off + const percentCoupon = await createPercentCoupon({ + stripeCli, + percentOff: 30, + }); - const amountCoupon = await createAmountCoupon({ - stripeCli, - amountOffCents: 500, // $5 - }); + const amountCoupon = await createAmountCoupon({ + stripeCli, + amountOffCents: 500, // $5 + }); - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: subscription.id, - couponIds: [percentCoupon.id, amountCoupon.id], - }); + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [percentCoupon.id, amountCoupon.id], + }); - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge: $100, 30% off = $70, then $5 off = $65 - // Total: -$50 + $65 = $15 - expect(preview.total).toBe(15); - }, -); + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge: $100, 30% off = $70, then $5 off = $65 + // Total: -$50 + $65 = $15 + expect(preview.total).toBe(15); +}); -test.concurrent( - `${chalk.yellowBright("stacking: two amount-off discounts (additive)")}`, - async () => { - const customerId = "stack-amt-amt"; +test.concurrent(`${chalk.yellowBright("stacking: two amount-off discounts (additive)")}`, async () => { + const customerId = "stack-amt-amt"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - // $10 off + $15 off - const coupon1 = await createAmountCoupon({ - stripeCli, - amountOffCents: 1000, // $10 - }); + // $10 off + $15 off + const coupon1 = await createAmountCoupon({ + stripeCli, + amountOffCents: 1000, // $10 + }); - const coupon2 = await createAmountCoupon({ - stripeCli, - amountOffCents: 1500, // $15 - }); + const coupon2 = await createAmountCoupon({ + stripeCli, + amountOffCents: 1500, // $15 + }); - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: subscription.id, - couponIds: [coupon1.id, coupon2.id], - }); + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [coupon1.id, coupon2.id], + }); - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge: $100, $10 off = $90, $15 off = $75 - // Total: -$50 + $75 = $25 - expect(preview.total).toBe(25); - }, -); + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge: $100, $10 off = $90, $15 off = $75 + // Total: -$50 + $75 = $25 + expect(preview.total).toBe(25); +}); -test.concurrent( - `${chalk.yellowBright("stacking: three discounts (two percent + one amount)")}`, - async () => { - const customerId = "stack-three"; +test.concurrent(`${chalk.yellowBright("stacking: three discounts (two percent + one amount)")}`, async () => { + const customerId = "stack-three"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - // 20% + 10% + $5 - const pct1 = await createPercentCoupon({ - stripeCli, - percentOff: 20, - }); + // 20% + 10% + $5 + const pct1 = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); - const pct2 = await createPercentCoupon({ - stripeCli, - percentOff: 10, - }); + const pct2 = await createPercentCoupon({ + stripeCli, + percentOff: 10, + }); - const amt = await createAmountCoupon({ - stripeCli, - amountOffCents: 500, // $5 - }); + const amt = await createAmountCoupon({ + stripeCli, + amountOffCents: 500, // $5 + }); - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: subscription.id, - couponIds: [pct1.id, pct2.id, amt.id], - }); + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [pct1.id, pct2.id, amt.id], + }); - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge: $100, 20% off = $80, 10% off = $72, $5 off = $67 - // Total: -$50 + $67 = $17 - expect(preview.total).toBe(17); - }, -); + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge: $100, 20% off = $80, 10% off = $72, $5 off = $67 + // Total: -$50 + $67 = $17 + expect(preview.total).toBe(17); +}); -test.concurrent( - `${chalk.yellowBright("stacking: order independence (amount listed before percent)")}`, - async () => { - const customerId = "stack-order-test"; +test.concurrent(`${chalk.yellowBright("stacking: order independence (amount listed before percent)")}`, async () => { + const customerId = "stack-order-test"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - // Create amount first, then percent (opposite order) - const amountCoupon = await createAmountCoupon({ - stripeCli, - amountOffCents: 1000, // $10 - }); + // Create amount first, then percent (opposite order) + const amountCoupon = await createAmountCoupon({ + stripeCli, + amountOffCents: 1000, // $10 + }); - const percentCoupon = await createPercentCoupon({ - stripeCli, - percentOff: 20, - }); + const percentCoupon = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); - // Apply in "wrong" order - amount first, percent second - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: subscription.id, - couponIds: [amountCoupon.id, percentCoupon.id], - }); + // Apply in "wrong" order - amount first, percent second + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [amountCoupon.id, percentCoupon.id], + }); - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Regardless of order in array, percent is applied first: - // Charge: $100, 20% off = $80, $10 off = $70 - // Total: -$50 + $70 = $20 - expect(preview.total).toBe(20); - }, -); + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Regardless of order in array, percent is applied first: + // Charge: $100, 20% off = $80, $10 off = $70 + // Total: -$50 + $70 = $20 + expect(preview.total).toBe(20); +}); -test.concurrent( - `${chalk.yellowBright("stacking: total discount capped at charge amount")}`, - async () => { - const customerId = "stack-cap-total"; +test.concurrent(`${chalk.yellowBright("stacking: total discount capped at charge amount")}`, async () => { + const customerId = "stack-cap-total"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - // 50% off + $30 off (totals more than $50 charge) - const percentCoupon = await createPercentCoupon({ - stripeCli, - percentOff: 50, - }); + // 50% off + $30 off (totals more than $50 charge) + const percentCoupon = await createPercentCoupon({ + stripeCli, + percentOff: 50, + }); - const amountCoupon = await createAmountCoupon({ - stripeCli, - amountOffCents: 3000, // $30 - }); + const amountCoupon = await createAmountCoupon({ + stripeCli, + amountOffCents: 3000, // $30 + }); - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: subscription.id, - couponIds: [percentCoupon.id, amountCoupon.id], - }); + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [percentCoupon.id, amountCoupon.id], + }); - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge: $100, 50% off = $50, $30 off = $20 - // Total: -$50 + $20 = -$30 - expect(preview.total).toBe(-30); - }, -); + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge: $100, 50% off = $50, $30 off = $20 + // Total: -$50 + $20 = -$30 + expect(preview.total).toBe(-30); +}); diff --git a/server/tests/billing/update-subscription/discounts/proration-discount.test.ts b/server/tests/billing/update-subscription/discounts/proration-discount.test.ts index c2f4788ef..4c10cf6b2 100644 --- a/server/tests/billing/update-subscription/discounts/proration-discount.test.ts +++ b/server/tests/billing/update-subscription/discounts/proration-discount.test.ts @@ -9,329 +9,318 @@ import { expect, test } from "bun:test"; import { applyProration } from "@autumn/shared"; -import { Decimal } from "decimal.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; +import { Decimal } from "decimal.js"; import { - getStripeSubscription, - createPercentCoupon, applySubscriptionDiscount, + createPercentCoupon, + getStripeSubscription, } from "../../utils/discounts/discountTestUtils.js"; const billingUnits = 12; const pricePerUnit = 10; -test.concurrent( - `${chalk.yellowBright("proration: mid-cycle upgrade with discount")}`, - async () => { - const customerId = "proration-mid-upgrade"; +test.concurrent(`${chalk.yellowBright("proration: mid-cycle upgrade with discount")}`, async () => { + const customerId = "proration-mid-upgrade"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1, advancedTo } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: true }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - // Advance 15 days into the billing cycle - s.advanceTestClock({ days: 15 }), - ], - }); + const { autumnV1, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + // Advance 15 days into the billing cycle + s.advanceTestClock({ days: 15 }), + ], + }); - // Get billing period from customer's subscription - const customer = await autumnV1.customers.get(customerId); - const subscription = customer.products?.[0]; + // Get billing period from customer's subscription + const customer = await autumnV1.customers.get(customerId); + const subscription = customer.products?.[0]; - if ( - !subscription?.current_period_start || - !subscription?.current_period_end - ) { - throw new Error("Missing billing period on subscription"); - } + if ( + !subscription?.current_period_start || + !subscription?.current_period_end + ) { + throw new Error("Missing billing period on subscription"); + } - const billingPeriod = { - start: subscription.current_period_start, - end: subscription.current_period_end, - }; + const billingPeriod = { + start: subscription.current_period_start, + end: subscription.current_period_end, + }; - const { stripeCli, subscription: stripeSub } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription: stripeSub } = await getStripeSubscription({ + customerId, + }); - const coupon = await createPercentCoupon({ - stripeCli, - percentOff: 20, - }); + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: stripeSub.id, - couponIds: [coupon.id], - }); + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: stripeSub.id, + couponIds: [coupon.id], + }); - // Preview upgrade from 5 to 10 units mid-cycle - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + // Preview upgrade from 5 to 10 units mid-cycle + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (prorated -$50) + charge (prorated $100) - // Discounts only apply to charges, not refunds - // Use floored seconds to match Stripe's frozen_time calculation - const frozenTimeMs = Math.floor(advancedTo! / 1000) * 1000; + // Upgrade generates: refund (prorated -$50) + charge (prorated $100) + // Discounts only apply to charges, not refunds + // Use floored seconds to match Stripe's frozen_time calculation + const frozenTimeMs = Math.floor(advancedTo! / 1000) * 1000; - const baseRefundAmount = 5 * pricePerUnit; // $50 for 5 units - const baseChargeAmount = 10 * pricePerUnit; // $100 for 10 units + const baseRefundAmount = 5 * pricePerUnit; // $50 for 5 units + const baseChargeAmount = 10 * pricePerUnit; // $100 for 10 units - const proratedRefund = applyProration({ - now: frozenTimeMs, - billingPeriod, - amount: baseRefundAmount, - }); + const proratedRefund = applyProration({ + now: frozenTimeMs, + billingPeriod, + amount: baseRefundAmount, + }); - const proratedCharge = applyProration({ - now: frozenTimeMs, - billingPeriod, - amount: baseChargeAmount, - }); + const proratedCharge = applyProration({ + now: frozenTimeMs, + billingPeriod, + amount: baseChargeAmount, + }); - // Refund is not discounted, charge gets 20% off - const discountedCharge = new Decimal(proratedCharge).times(0.8).toNumber(); - const expectedAmount = -proratedRefund + discountedCharge; + // Refund is not discounted, charge gets 20% off + const discountedCharge = new Decimal(proratedCharge).times(0.8).toNumber(); + const expectedAmount = -proratedRefund + discountedCharge; - // Use toBeCloseTo due to proration timing precision differences - expect(preview.total).toBeCloseTo(expectedAmount, 0); - }, -); + // Use toBeCloseTo due to proration timing precision differences + expect(preview.total).toBeCloseTo(expectedAmount, 0); +}); -test.concurrent( - `${chalk.yellowBright("proration: mid-cycle downgrade (refund not discounted)")}`, - async () => { - const customerId = "proration-mid-downgrade"; +test.concurrent(`${chalk.yellowBright("proration: mid-cycle downgrade (refund not discounted)")}`, async () => { + const customerId = "proration-mid-downgrade"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1, advancedTo } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: true }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - // Start with 10 units - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }), - // Advance 15 days into the billing cycle - s.advanceTestClock({ days: 15 }), - ], - }); + const { autumnV1, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + // Start with 10 units + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }), + // Advance 15 days into the billing cycle + s.advanceTestClock({ days: 15 }), + ], + }); - // Get billing period from customer's subscription - const customer = await autumnV1.customers.get(customerId); - const subscription = customer.products?.[0]; + // Get billing period from customer's subscription + const customer = await autumnV1.customers.get(customerId); + const subscription = customer.products?.[0]; - if ( - !subscription?.current_period_start || - !subscription?.current_period_end - ) { - throw new Error("Missing billing period on subscription"); - } + if ( + !subscription?.current_period_start || + !subscription?.current_period_end + ) { + throw new Error("Missing billing period on subscription"); + } - const billingPeriod = { - start: subscription.current_period_start, - end: subscription.current_period_end, - }; + const billingPeriod = { + start: subscription.current_period_start, + end: subscription.current_period_end, + }; - const { stripeCli, subscription: stripeSub } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription: stripeSub } = await getStripeSubscription({ + customerId, + }); - // Apply a 50% discount - const coupon = await createPercentCoupon({ - stripeCli, - percentOff: 50, - }); + // Apply a 50% discount + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 50, + }); - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: stripeSub.id, - couponIds: [coupon.id], - }); + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: stripeSub.id, + couponIds: [coupon.id], + }); - // Preview downgrade from 10 to 5 units mid-cycle - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }); + // Preview downgrade from 10 to 5 units mid-cycle + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: 5 * billingUnits }], + }); - // Downgrade generates: refund (prorated -$100 for 10 units) + charge (prorated $50 for 5 units) - // Discounts only apply to charges, not refunds - // Use floored seconds to match Stripe's frozen_time calculation - const frozenTimeMs = Math.floor(advancedTo! / 1000) * 1000; + // Downgrade generates: refund (prorated -$100 for 10 units) + charge (prorated $50 for 5 units) + // Discounts only apply to charges, not refunds + // Use floored seconds to match Stripe's frozen_time calculation + const frozenTimeMs = Math.floor(advancedTo! / 1000) * 1000; - const baseRefundAmount = 10 * pricePerUnit; // $100 for old 10 units - const baseChargeAmount = 5 * pricePerUnit; // $50 for new 5 units + const baseRefundAmount = 10 * pricePerUnit; // $100 for old 10 units + const baseChargeAmount = 5 * pricePerUnit; // $50 for new 5 units - const proratedRefund = applyProration({ - now: frozenTimeMs, - billingPeriod, - amount: baseRefundAmount, - }); + const proratedRefund = applyProration({ + now: frozenTimeMs, + billingPeriod, + amount: baseRefundAmount, + }); - const proratedCharge = applyProration({ - now: frozenTimeMs, - billingPeriod, - amount: baseChargeAmount, - }); + const proratedCharge = applyProration({ + now: frozenTimeMs, + billingPeriod, + amount: baseChargeAmount, + }); - // Refund is not discounted, charge gets 50% off - const discountedCharge = new Decimal(proratedCharge).times(0.5).toNumber(); - const expectedAmount = -proratedRefund + discountedCharge; + // Refund is not discounted, charge gets 50% off + const discountedCharge = new Decimal(proratedCharge).times(0.5).toNumber(); + const expectedAmount = -proratedRefund + discountedCharge; - // Use toBeCloseTo due to proration timing precision differences - expect(preview.total).toBeCloseTo(expectedAmount, 0); - }, -); + // Use toBeCloseTo due to proration timing precision differences + expect(preview.total).toBeCloseTo(expectedAmount, 0); +}); -test.concurrent( - `${chalk.yellowBright("proration: discount on upgrade with proration")}`, - async () => { - const customerId = "proration-upgrade-discount"; +test.concurrent(`${chalk.yellowBright("proration: discount on upgrade with proration")}`, async () => { + const customerId = "proration-upgrade-discount"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1, testClockId, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: true }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1, testClockId, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - // Get billing period from customer's subscription - const customer = await autumnV1.customers.get(customerId); - const subscription = customer.products?.[0]; + // Get billing period from customer's subscription + const customer = await autumnV1.customers.get(customerId); + const subscription = customer.products?.[0]; - if ( - !subscription?.current_period_start || - !subscription?.current_period_end - ) { - throw new Error("Missing billing period on subscription"); - } + if ( + !subscription?.current_period_start || + !subscription?.current_period_end + ) { + throw new Error("Missing billing period on subscription"); + } - const billingPeriod = { - start: subscription.current_period_start, - end: subscription.current_period_end, - }; + const billingPeriod = { + start: subscription.current_period_start, + end: subscription.current_period_end, + }; - // Get frozen time from Stripe test clock (matches what backend uses) - const testClock = await ctx.stripeCli.testHelpers.testClocks.retrieve( - testClockId!, - ); - const frozenTimeMs = testClock.frozen_time * 1000; + // Get frozen time from Stripe test clock (matches what backend uses) + const testClock = await ctx.stripeCli.testHelpers.testClocks.retrieve( + testClockId!, + ); + const frozenTimeMs = testClock.frozen_time * 1000; - const { stripeCli, subscription: stripeSub } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription: stripeSub } = await getStripeSubscription({ + customerId, + }); - // Apply discount before upgrade - const coupon = await createPercentCoupon({ - stripeCli, - percentOff: 30, - }); + // Apply discount before upgrade + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 30, + }); - await applySubscriptionDiscount({ - stripeCli, - subscriptionId: stripeSub.id, - couponIds: [coupon.id], - }); + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: stripeSub.id, + couponIds: [coupon.id], + }); - // Preview upgrade (subscription just created, so nearly full period remaining) - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + // Preview upgrade (subscription just created, so nearly full period remaining) + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (prorated -$50) + charge (prorated $100) - // Discounts only apply to charges, not refunds - const baseRefundAmount = 5 * pricePerUnit; // $50 for 5 units - const baseChargeAmount = 10 * pricePerUnit; // $100 for 10 units + // Upgrade generates: refund (prorated -$50) + charge (prorated $100) + // Discounts only apply to charges, not refunds + const baseRefundAmount = 5 * pricePerUnit; // $50 for 5 units + const baseChargeAmount = 10 * pricePerUnit; // $100 for 10 units - const proratedRefund = applyProration({ - now: frozenTimeMs, - billingPeriod, - amount: baseRefundAmount, - }); + const proratedRefund = applyProration({ + now: frozenTimeMs, + billingPeriod, + amount: baseRefundAmount, + }); - const proratedCharge = applyProration({ - now: frozenTimeMs, - billingPeriod, - amount: baseChargeAmount, - }); + const proratedCharge = applyProration({ + now: frozenTimeMs, + billingPeriod, + amount: baseChargeAmount, + }); - // Refund is not discounted, charge gets 30% off - const discountedCharge = new Decimal(proratedCharge).times(0.7).toNumber(); - const expectedAmount = -proratedRefund + discountedCharge; + // Refund is not discounted, charge gets 30% off + const discountedCharge = new Decimal(proratedCharge).times(0.7).toNumber(); + const expectedAmount = -proratedRefund + discountedCharge; - expect(preview.total).toBe(expectedAmount); - }, -); + expect(preview.total).toBe(expectedAmount); +}); diff --git a/server/tests/billing/update-subscription/discounts/subscription-discounts.test.ts b/server/tests/billing/update-subscription/discounts/subscription-discounts.test.ts index 7dccd57de..e76e6a62d 100644 --- a/server/tests/billing/update-subscription/discounts/subscription-discounts.test.ts +++ b/server/tests/billing/update-subscription/discounts/subscription-discounts.test.ts @@ -62,482 +62,461 @@ const getStripeSubscription = async ({ // PERCENT-OFF DISCOUNT TESTS // ============================================================================= -test.concurrent( - `${chalk.yellowBright("discount: 20% off subscription discount applied to upgrade")}`, - async () => { - const customerId = "discount-20pct-upgrade"; +test.concurrent(`${chalk.yellowBright("discount: 20% off subscription discount applied to upgrade")}`, async () => { + const customerId = "discount-20pct-upgrade"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - // Create a 20% off coupon and apply to subscription - const coupon = await stripeCli.coupons.create({ - percent_off: 20, - duration: "forever", - }); + // Create a 20% off coupon and apply to subscription + const coupon = await stripeCli.coupons.create({ + percent_off: 20, + duration: "forever", + }); - await stripeCli.subscriptions.update(subscription.id, { - discounts: [{ coupon: coupon.id }], - }); + await stripeCli.subscriptions.update(subscription.id, { + discounts: [{ coupon: coupon.id }], + }); - // Preview upgrade from 5 to 10 units (adding 5 units) - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + // Preview upgrade from 5 to 10 units (adding 5 units) + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge with 20% off: $100 * 0.8 = $80 - // Total: -$50 + $80 = $30 - const refundAmount = -50; - const discountedCharge = Math.round(100 * 0.8); - const expectedAmount = refundAmount + discountedCharge; + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge with 20% off: $100 * 0.8 = $80 + // Total: -$50 + $80 = $30 + const refundAmount = -50; + const discountedCharge = Math.round(100 * 0.8); + const expectedAmount = refundAmount + discountedCharge; - expect(preview.total).toBe(expectedAmount); - }, -); + expect(preview.total).toBe(expectedAmount); +}); -test.concurrent( - `${chalk.yellowBright("discount: 50% off subscription discount")}`, - async () => { - const customerId = "discount-50pct-upgrade"; +test.concurrent(`${chalk.yellowBright("discount: 50% off subscription discount")}`, async () => { + const customerId = "discount-50pct-upgrade"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - const coupon = await stripeCli.coupons.create({ - percent_off: 50, - duration: "forever", - }); + const coupon = await stripeCli.coupons.create({ + percent_off: 50, + duration: "forever", + }); - await stripeCli.subscriptions.update(subscription.id, { - discounts: [{ coupon: coupon.id }], - }); + await stripeCli.subscriptions.update(subscription.id, { + discounts: [{ coupon: coupon.id }], + }); - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge with 50% off: $100 * 0.5 = $50 - // Total: -$50 + $50 = $0 - const refundAmount = -50; - const discountedCharge = Math.round(100 * 0.5); - const expectedAmount = refundAmount + discountedCharge; + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge with 50% off: $100 * 0.5 = $50 + // Total: -$50 + $50 = $0 + const refundAmount = -50; + const discountedCharge = Math.round(100 * 0.5); + const expectedAmount = refundAmount + discountedCharge; - expect(preview.total).toBe(expectedAmount); - }, -); + expect(preview.total).toBe(expectedAmount); +}); -test.concurrent( - `${chalk.yellowBright("discount: 100% off subscription discount (free)")}`, - async () => { - const customerId = "discount-100pct-free"; +test.concurrent(`${chalk.yellowBright("discount: 100% off subscription discount (free)")}`, async () => { + const customerId = "discount-100pct-free"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - const coupon = await stripeCli.coupons.create({ - percent_off: 100, - duration: "forever", - }); + const coupon = await stripeCli.coupons.create({ + percent_off: 100, + duration: "forever", + }); - await stripeCli.subscriptions.update(subscription.id, { - discounts: [{ coupon: coupon.id }], - }); + await stripeCli.subscriptions.update(subscription.id, { + discounts: [{ coupon: coupon.id }], + }); - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge with 100% off: $100 * 0 = $0 - // Total: -$50 + $0 = -$50 - expect(preview.total).toBe(-50); - }, -); + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge with 100% off: $100 * 0 = $0 + // Total: -$50 + $0 = -$50 + expect(preview.total).toBe(-50); +}); // ============================================================================= // AMOUNT-OFF DISCOUNT TESTS // ============================================================================= -test.concurrent( - `${chalk.yellowBright("discount: $10 off amount discount applied to upgrade")}`, - async () => { - const customerId = "discount-10dollars-upgrade"; +test.concurrent(`${chalk.yellowBright("discount: $10 off amount discount applied to upgrade")}`, async () => { + const customerId = "discount-10dollars-upgrade"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - // $10 off coupon (1000 cents) - amount_off requires repeating duration - const coupon = await stripeCli.coupons.create({ - amount_off: 1000, - currency: "usd", - duration: "repeating", - duration_in_months: 12, - }); + // $10 off coupon (1000 cents) - amount_off requires repeating duration + const coupon = await stripeCli.coupons.create({ + amount_off: 1000, + currency: "usd", + duration: "repeating", + duration_in_months: 12, + }); - await stripeCli.subscriptions.update(subscription.id, { - discounts: [{ coupon: coupon.id }], - }); + await stripeCli.subscriptions.update(subscription.id, { + discounts: [{ coupon: coupon.id }], + }); - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge with $10 off: $100 - $10 = $90 - // Total: -$50 + $90 = $40 - const refundAmount = -50; - const discountedCharge = 100 - 10; - const expectedAmount = refundAmount + discountedCharge; + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge with $10 off: $100 - $10 = $90 + // Total: -$50 + $90 = $40 + const refundAmount = -50; + const discountedCharge = 100 - 10; + const expectedAmount = refundAmount + discountedCharge; - expect(preview.total).toBe(expectedAmount); - }, -); + expect(preview.total).toBe(expectedAmount); +}); -test.concurrent( - `${chalk.yellowBright("discount: charge capped at zero when discount exceeds charge")}`, - async () => { - const customerId = "discount-cap-at-zero"; +test.concurrent(`${chalk.yellowBright("discount: charge capped at zero when discount exceeds charge")}`, async () => { + const customerId = "discount-cap-at-zero"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - // $100 off coupon (10000 cents) - more than the $100 new charge - // amount_off requires repeating duration - const coupon = await stripeCli.coupons.create({ - amount_off: 10000, - currency: "usd", - duration: "repeating", - duration_in_months: 12, - }); + // $100 off coupon (10000 cents) - more than the $100 new charge + // amount_off requires repeating duration + const coupon = await stripeCli.coupons.create({ + amount_off: 10000, + currency: "usd", + duration: "repeating", + duration_in_months: 12, + }); - await stripeCli.subscriptions.update(subscription.id, { - discounts: [{ coupon: coupon.id }], - }); + await stripeCli.subscriptions.update(subscription.id, { + discounts: [{ coupon: coupon.id }], + }); - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Charge is capped at 0 (100 - 100 = 0), but refund for unused still applies - // Net = -$50 (refund) + $0 (discounted charge) = -$50 - expect(preview.total).toBe(-50); - }, -); + // Charge is capped at 0 (100 - 100 = 0), but refund for unused still applies + // Net = -$50 (refund) + $0 (discounted charge) = -$50 + expect(preview.total).toBe(-50); +}); // ============================================================================= // MULTIPLE DISCOUNT TESTS // ============================================================================= -test.concurrent( - `${chalk.yellowBright("discount: multiple discounts stack (20% + 10%)")}`, - async () => { - const customerId = "discount-multiple-stack"; +test.concurrent(`${chalk.yellowBright("discount: multiple discounts stack (20% + 10%)")}`, async () => { + const customerId = "discount-multiple-stack"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - // Create two coupons: 20% and 10% - const coupon1 = await stripeCli.coupons.create({ - percent_off: 20, - duration: "forever", - }); + // Create two coupons: 20% and 10% + const coupon1 = await stripeCli.coupons.create({ + percent_off: 20, + duration: "forever", + }); - const coupon2 = await stripeCli.coupons.create({ - percent_off: 10, - duration: "forever", - }); + const coupon2 = await stripeCli.coupons.create({ + percent_off: 10, + duration: "forever", + }); - // Apply both discounts to subscription - await stripeCli.subscriptions.update(subscription.id, { - discounts: [{ coupon: coupon1.id }, { coupon: coupon2.id }], - }); + // Apply both discounts to subscription + await stripeCli.subscriptions.update(subscription.id, { + discounts: [{ coupon: coupon1.id }, { coupon: coupon2.id }], + }); - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge: $100, 20% off = $80, then 10% off = $72 - // Total: -$50 + $72 = $22 - const refundAmount = -50; - const afterFirstDiscount = Math.round(100 * 0.8); - const discountedCharge = Math.round(afterFirstDiscount * 0.9); - const expectedAmount = refundAmount + discountedCharge; + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge: $100, 20% off = $80, then 10% off = $72 + // Total: -$50 + $72 = $22 + const refundAmount = -50; + const afterFirstDiscount = Math.round(100 * 0.8); + const discountedCharge = Math.round(afterFirstDiscount * 0.9); + const expectedAmount = refundAmount + discountedCharge; - expect(preview.total).toBe(expectedAmount); - }, -); + expect(preview.total).toBe(expectedAmount); +}); -test.concurrent( - `${chalk.yellowBright("discount: promotion code applied to subscription")}`, - async () => { - const customerId = "discount-promo-code"; +test.concurrent(`${chalk.yellowBright("discount: promotion code applied to subscription")}`, async () => { + const customerId = "discount-promo-code"; - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - }), - ], - }); + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 5 * billingUnits }, + ], + }), + ], + }); - const { stripeCli, subscription } = await getStripeSubscription({ - customerId, - }); + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); - // Create a coupon and promotion code - const coupon = await stripeCli.coupons.create({ - percent_off: 25, - duration: "forever", - }); + // Create a coupon and promotion code + const coupon = await stripeCli.coupons.create({ + percent_off: 25, + duration: "forever", + }); - const promotionCode = await stripeCli.promotionCodes.create({ - promotion: { - type: "coupon", - coupon: coupon.id, - }, - code: `SAVE25-${customerId}-${Date.now()}`, - }); + const promotionCode = await stripeCli.promotionCodes.create({ + promotion: { + type: "coupon", + coupon: coupon.id, + }, + code: `SAVE25-${customerId}-${Date.now()}`, + }); - // Apply via promotion code - await stripeCli.subscriptions.update(subscription.id, { - discounts: [{ promotion_code: promotionCode.id }], - }); + // Apply via promotion code + await stripeCli.subscriptions.update(subscription.id, { + discounts: [{ promotion_code: promotionCode.id }], + }); - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }); + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, + ], + }); - // Upgrade generates: refund (-$50) + charge ($100) - // Discounts only apply to charges, not refunds - // Charge with 25% off: $100 * 0.75 = $75 - // Total: -$50 + $75 = $25 - const refundAmount = -50; - const discountedCharge = Math.round(100 * 0.75); - const expectedAmount = refundAmount + discountedCharge; + // Upgrade generates: refund (-$50) + charge ($100) + // Discounts only apply to charges, not refunds + // Charge with 25% off: $100 * 0.75 = $75 + // Total: -$50 + $75 = $25 + const refundAmount = -50; + const discountedCharge = Math.round(100 * 0.75); + const expectedAmount = refundAmount + discountedCharge; - expect(preview.total).toBe(expectedAmount); - }, -); + expect(preview.total).toBe(expectedAmount); +}); diff --git a/shared/utils/utils.ts b/shared/utils/utils.ts index bc8bbd60f..d5a9e7533 100644 --- a/shared/utils/utils.ts +++ b/shared/utils/utils.ts @@ -1,4 +1,5 @@ import { KSUID } from "@owpz/ksuid"; +import { Decimal } from "decimal.js"; export const generateId = (prefix?: string): string => { const id = KSUID.random().toString(); @@ -17,7 +18,7 @@ export const notNullish = (value: T | null | undefined): value is T => export const idRegex = /^[a-zA-Z0-9_-]+$/; export const sumValues = (vals: number[]) => { - return vals.reduce((acc, curr) => acc + curr, 0); + return vals.reduce((acc, curr) => acc.add(curr), new Decimal(0)).toNumber(); }; export const keyToTitle = (