minor cleanup

This commit is contained in:
John Yeo
2026-01-12 12:15:19 +00:00
parent 7aa1df0ef7
commit 8de38b7601
10 changed files with 1332 additions and 1371 deletions

View File

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

View File

@@ -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}`;
};

View File

@@ -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<LineItem, number>();
// 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,
};

View File

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

View File

@@ -49,6 +49,7 @@ export const initCustomerV3 = async ({
});
// 2. Create customer
try {
await autumn.customers.delete(customerId);
} catch (_error) {}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = <T>(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 = (