minor cleanup

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

View File

@@ -1,9 +1,9 @@
import type { FullCusProduct, FullCustomer } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { fetchStripeCustomerForBilling } from "./fetchStripeCustomerForBilling";
import { setupStripeDiscountsForBilling } from "./setupStripeDiscountsForBilling";
import { fetchStripeSubscriptionForBilling } from "./fetchStripeSubscriptionForBilling";
import { fetchStripeSubscriptionScheduleForBilling } from "./fetchStripeSubscriptionScheduleForBilling";
import { setupStripeDiscountsForBilling } from "./setupStripeDiscountsForBilling";
export const setupStripeBillingContext = async ({
ctx,

View File

@@ -0,0 +1,13 @@
const DISCOUNT_TAG = "[inc. discount]";
/** Suffixes "[inc. discount]" to description if not already present */
export const addDiscountTagToDescription = ({
description,
}: {
description: string;
}): string => {
if (description.includes(DISCOUNT_TAG)) {
return description;
}
return `${description} ${DISCOUNT_TAG}`;
};

View File

@@ -3,8 +3,10 @@ import {
type LineItemDiscount,
type StripeDiscountWithCoupon,
stripeToAtmnAmount,
sumValues,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { addDiscountTagToDescription } from "./addDiscountTagToDescription";
import { discountAppliesToLineItem } from "./discountAppliesToLineItem";
/**
@@ -33,8 +35,8 @@ export const applyAmountOffDiscountToLineItems = ({
// Filter to applicable CHARGE line items only
// Discounts reduce what the customer pays, so only apply to charges
const applicableChargeItems = lineItems.filter(
(item) => discountAppliesToLineItem({ discount, lineItem: item }),
const applicableChargeItems = lineItems.filter((item) =>
discountAppliesToLineItem({ discount, lineItem: item }),
);
if (applicableChargeItems.length === 0) return lineItems;
@@ -43,9 +45,8 @@ export const applyAmountOffDiscountToLineItems = ({
const discountMap = new Map<LineItem, number>();
// Distribute discount proportionally across charge items
const total = applicableChargeItems.reduce(
(sum, item) => sum + Math.abs(item.amount),
0,
const total = sumValues(
applicableChargeItems.map((item) => Math.abs(item.amount)),
);
if (total === 0) return lineItems;
@@ -80,6 +81,9 @@ export const applyAmountOffDiscountToLineItems = ({
return {
...item,
description: addDiscountTagToDescription({
description: item.description,
}),
discounts: [...existingDiscounts, newDiscount],
finalAmount,
};

View File

@@ -4,6 +4,7 @@ import type {
StripeDiscountWithCoupon,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { addDiscountTagToDescription } from "./addDiscountTagToDescription";
import { discountAppliesToLineItem } from "./discountAppliesToLineItem";
/**
@@ -60,6 +61,9 @@ export const applyPercentOffDiscountToLineItems = ({
return {
...item,
description: addDiscountTagToDescription({
description: item.description,
}),
discounts: [...existingDiscounts, newDiscount],
finalAmount,
};

View File

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

View File

@@ -14,17 +14,15 @@ 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 () => {
test.concurrent(`${chalk.yellowBright("entity: discount on single entity upgrade")}`, async () => {
const customerId = "entity-single-upgrade";
const product = products.base({
@@ -97,12 +95,9 @@ test.concurrent(
const expectedAmount = refundAmount + discountedCharge;
expect(preview.total).toBe(expectedAmount);
},
);
});
test.concurrent(
`${chalk.yellowBright("entity: both entities share subscription discount")}`,
async () => {
test.concurrent(`${chalk.yellowBright("entity: both entities share subscription discount")}`, async () => {
const customerId = "entity-shared-discount";
const product = products.base({
@@ -186,12 +181,9 @@ test.concurrent(
expect(preview0.total).toBe(expectedAmount);
expect(preview1.total).toBe(expectedAmount);
},
);
});
test.concurrent(
`${chalk.yellowBright("entity: upgrade one entity while other unchanged")}`,
async () => {
test.concurrent(`${chalk.yellowBright("entity: upgrade one entity while other unchanged")}`, async () => {
const customerId = "entity-partial-upgrade";
const product = products.base({
@@ -282,12 +274,9 @@ test.concurrent(
const feature = entity1.features?.[TestFeature.Messages];
expect(feature?.balance).toBe(initialQuantity);
},
);
});
test.concurrent(
`${chalk.yellowBright("entity: discount on entity product switch")}`,
async () => {
test.concurrent(`${chalk.yellowBright("entity: discount on entity product switch")}`, async () => {
const customerId = "entity-product-switch";
const basicProduct = products.base({
@@ -373,5 +362,4 @@ test.concurrent(
const expectedAmount = refundAmount + discountedCharge;
expect(preview.total).toBe(expectedAmount);
},
);
});

View File

@@ -14,10 +14,10 @@ import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import {
getStripeSubscription,
createPercentCoupon,
createAmountCoupon,
applySubscriptionDiscount,
createAmountCoupon,
createPercentCoupon,
getStripeSubscription,
} from "../../utils/discounts/discountTestUtils.js";
const billingUnits = 12;
@@ -27,9 +27,7 @@ const pricePerUnit = 10;
// MIGRATED TESTS FROM subscription-discounts.test.ts
// =============================================================================
test.concurrent(
`${chalk.yellowBright("stacking: two percent-off discounts (multiplicative)")}`,
async () => {
test.concurrent(`${chalk.yellowBright("stacking: two percent-off discounts (multiplicative)")}`, async () => {
const customerId = "stack-pct-pct";
const product = products.base({
@@ -99,16 +97,13 @@ test.concurrent(
const expectedAmount = refundAmount + discountedCharge;
expect(preview.total).toBe(expectedAmount);
},
);
});
// =============================================================================
// NEW STACKING TESTS
// =============================================================================
test.concurrent(
`${chalk.yellowBright("stacking: percent then amount (percent applied first)")}`,
async () => {
test.concurrent(`${chalk.yellowBright("stacking: percent then amount (percent applied first)")}`, async () => {
const customerId = "stack-pct-amt";
const product = products.base({
@@ -172,12 +167,9 @@ test.concurrent(
// 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 () => {
test.concurrent(`${chalk.yellowBright("stacking: two amount-off discounts (additive)")}`, async () => {
const customerId = "stack-amt-amt";
const product = products.base({
@@ -241,12 +233,9 @@ test.concurrent(
// 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 () => {
test.concurrent(`${chalk.yellowBright("stacking: three discounts (two percent + one amount)")}`, async () => {
const customerId = "stack-three";
const product = products.base({
@@ -315,12 +304,9 @@ test.concurrent(
// 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 () => {
test.concurrent(`${chalk.yellowBright("stacking: order independence (amount listed before percent)")}`, async () => {
const customerId = "stack-order-test";
const product = products.base({
@@ -386,12 +372,9 @@ test.concurrent(
// 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 () => {
test.concurrent(`${chalk.yellowBright("stacking: total discount capped at charge amount")}`, async () => {
const customerId = "stack-cap-total";
const product = products.base({
@@ -455,5 +438,4 @@ test.concurrent(
// Charge: $100, 50% off = $50, $30 off = $20
// Total: -$50 + $20 = -$30
expect(preview.total).toBe(-30);
},
);
});

View File

@@ -9,24 +9,22 @@
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 () => {
test.concurrent(`${chalk.yellowBright("proration: mid-cycle upgrade with discount")}`, async () => {
const customerId = "proration-mid-upgrade";
const product = products.base({
@@ -124,12 +122,9 @@ test.concurrent(
// 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 () => {
test.concurrent(`${chalk.yellowBright("proration: mid-cycle downgrade (refund not discounted)")}`, async () => {
const customerId = "proration-mid-downgrade";
const product = products.base({
@@ -198,9 +193,7 @@ test.concurrent(
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: product.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 5 * billingUnits },
],
options: [{ feature_id: TestFeature.Messages, quantity: 5 * billingUnits }],
});
// Downgrade generates: refund (prorated -$100 for 10 units) + charge (prorated $50 for 5 units)
@@ -229,12 +222,9 @@ test.concurrent(
// 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 () => {
test.concurrent(`${chalk.yellowBright("proration: discount on upgrade with proration")}`, async () => {
const customerId = "proration-upgrade-discount";
const product = products.base({
@@ -333,5 +323,4 @@ test.concurrent(
const expectedAmount = -proratedRefund + discountedCharge;
expect(preview.total).toBe(expectedAmount);
},
);
});

View File

@@ -62,9 +62,7 @@ const getStripeSubscription = async ({
// PERCENT-OFF DISCOUNT TESTS
// =============================================================================
test.concurrent(
`${chalk.yellowBright("discount: 20% off subscription discount applied to upgrade")}`,
async () => {
test.concurrent(`${chalk.yellowBright("discount: 20% off subscription discount applied to upgrade")}`, async () => {
const customerId = "discount-20pct-upgrade";
const product = products.base({
@@ -126,12 +124,9 @@ test.concurrent(
const expectedAmount = refundAmount + discountedCharge;
expect(preview.total).toBe(expectedAmount);
},
);
});
test.concurrent(
`${chalk.yellowBright("discount: 50% off subscription discount")}`,
async () => {
test.concurrent(`${chalk.yellowBright("discount: 50% off subscription discount")}`, async () => {
const customerId = "discount-50pct-upgrade";
const product = products.base({
@@ -191,12 +186,9 @@ test.concurrent(
const expectedAmount = refundAmount + discountedCharge;
expect(preview.total).toBe(expectedAmount);
},
);
});
test.concurrent(
`${chalk.yellowBright("discount: 100% off subscription discount (free)")}`,
async () => {
test.concurrent(`${chalk.yellowBright("discount: 100% off subscription discount (free)")}`, async () => {
const customerId = "discount-100pct-free";
const product = products.base({
@@ -252,16 +244,13 @@ test.concurrent(
// 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 () => {
test.concurrent(`${chalk.yellowBright("discount: $10 off amount discount applied to upgrade")}`, async () => {
const customerId = "discount-10dollars-upgrade";
const product = products.base({
@@ -324,12 +313,9 @@ test.concurrent(
const expectedAmount = refundAmount + discountedCharge;
expect(preview.total).toBe(expectedAmount);
},
);
});
test.concurrent(
`${chalk.yellowBright("discount: charge capped at zero when discount exceeds charge")}`,
async () => {
test.concurrent(`${chalk.yellowBright("discount: charge capped at zero when discount exceeds charge")}`, async () => {
const customerId = "discount-cap-at-zero";
const product = products.base({
@@ -387,16 +373,13 @@ test.concurrent(
// 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 () => {
test.concurrent(`${chalk.yellowBright("discount: multiple discounts stack (20% + 10%)")}`, async () => {
const customerId = "discount-multiple-stack";
const product = products.base({
@@ -464,12 +447,9 @@ test.concurrent(
const expectedAmount = refundAmount + discountedCharge;
expect(preview.total).toBe(expectedAmount);
},
);
});
test.concurrent(
`${chalk.yellowBright("discount: promotion code applied to subscription")}`,
async () => {
test.concurrent(`${chalk.yellowBright("discount: promotion code applied to subscription")}`, async () => {
const customerId = "discount-promo-code";
const product = products.base({
@@ -539,5 +519,4 @@ test.concurrent(
const expectedAmount = refundAmount + discountedCharge;
expect(preview.total).toBe(expectedAmount);
},
);
});

View File

@@ -1,4 +1,5 @@
import { KSUID } from "@owpz/ksuid";
import { Decimal } from "decimal.js";
export const generateId = (prefix?: string): string => {
const id = KSUID.random().toString();
@@ -17,7 +18,7 @@ export const notNullish = <T>(value: T | null | undefined): value is T =>
export const idRegex = /^[a-zA-Z0-9_-]+$/;
export const sumValues = (vals: number[]) => {
return vals.reduce((acc, curr) => acc + curr, 0);
return vals.reduce((acc, curr) => acc.add(curr), new Decimal(0)).toNumber();
};
export const keyToTitle = (