minor cleanup
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ export const initCustomerV3 = async ({
|
||||
});
|
||||
|
||||
// 2. Create customer
|
||||
|
||||
try {
|
||||
await autumn.customers.delete(customerId);
|
||||
} catch (_error) {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user