Merge branch 'feat/link-line-items-to-product' into dev

This commit is contained in:
John Yeo
2026-02-25 12:09:11 +00:00
29 changed files with 355 additions and 139 deletions

View File

@@ -17,16 +17,21 @@ import { logWebhookArrearLineItems } from "./logs/logWebhookArrearLineItems";
/**
* Generates arrear (usage-in-arrear) line items from webhook event context.
*
* This function is used by both:
* Used by:
* - `invoice.created` webhook: adds consumable usage line items to renewal invoice
* - `subscription.deleted` webhook: creates final arrear invoice for usage
*
* @param ctx - Autumn context (for org currency, etc.)
* @param eventContext - Common webhook context (stripeSubscription, stripeCustomer, fullCustomer, customerProducts, nowMs, paymentMethod)
* @param periodEndMs - End of billing period (optional, falls back to nowMs)
* @param cusEntFilter - Optional filter for multi-interval billing (invoice.created uses this)
* ## Discount handling
*
* @returns Object with line items and the billing context used
* Line items are created with `discountable: true`, which tells Stripe to auto-apply
* subscription/customer discounts when adding these items to an invoice.
*
* We also call `applyStripeDiscountsToLineItems` locally to calculate the discounted
* amounts for our own records (stored in `amountAfterDiscounts`). This is purely for
* audit/tracking purposes - Stripe handles the actual discount application.
*
* We use `skipDescriptionTag: true` so the description doesn't include "[inc. discount]"
* since we're not pre-deducting the discount from the amount sent to Stripe.
*/
export const eventContextToArrearLineItems = ({
ctx,
@@ -60,20 +65,26 @@ export const eventContextToArrearLineItems = ({
customerProduct,
billingContext,
filters: { cusEntFilter },
options: { updateNextResetAt: true },
options: { updateNextResetAt: true, discountable: true },
});
lineItems.push(...productLineItems);
updateCustomerEntitlements.push(...productUpdates);
}
// Apply discounts to line items
// Apply discounts to line items (for our DB records)
// Note: discountable: true lets Stripe auto-apply discounts, but we still
// need to track discounts on our side for accurate DB storage
const discounts = extractStripeDiscounts({
stripeSubscription: eventContext.stripeSubscription,
stripeCustomer: eventContext.stripeCustomer,
});
if (discounts.length > 0) {
lineItems = applyStripeDiscountsToLineItems({ lineItems, discounts });
lineItems = applyStripeDiscountsToLineItems({
lineItems,
discounts,
options: { skipDescriptionTag: true },
});
}
// Log the arrear line items and customer entitlement updates

View File

@@ -48,11 +48,7 @@ export const logCustomerProductUpdates = ({
status: customerProduct.status,
}));
if (
updates.length === 0 &&
deletions.length === 0 &&
insertions.length === 0
)
if (updates.length === 0 && deletions.length === 0 && insertions.length === 0)
return;
addToExtraLogs({

View File

@@ -1,6 +1,6 @@
import type { UpdateCustomerEntitlement } from "@autumn/shared";
import { formatMs, type LineItem } from "@autumn/shared";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import type { UpdateCustomerEntitlement } from "@autumn/shared";
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
export const logWebhookArrearLineItems = ({
@@ -16,13 +16,12 @@ export const logWebhookArrearLineItems = ({
ctx,
extras: {
arrearLineItems: {
lineItems: lineItems.map((item) => {
const hasDiscount =
item.finalAmount !== undefined && item.finalAmount !== item.amount;
return hasDiscount
? `${item.description}: ${item.amount}${item.finalAmount} (discounted)`
: `${item.description}: ${item.amount}`;
}),
lineItems: lineItems.map((item) => ({
description: item.description,
amount: item.amount,
amountAfterDiscounts: item.amountAfterDiscounts,
discountable: item.context.discountable ?? false,
})),
updateCustomerEntitlements: updateCustomerEntitlements.map(
(update) => ({
featureId: update.customerEntitlement.entitlement.feature?.id,

View File

@@ -111,8 +111,8 @@ export const computeUpdateQuantityLineItems = ({
// Don't return line items if they sum to 0
if (
sumValues([
refundLineItem?.finalAmount ?? 0,
chargeLineItem?.finalAmount ?? 0,
refundLineItem?.amountAfterDiscounts ?? 0,
chargeLineItem?.amountAfterDiscounts ?? 0,
]) === 0
) {
return [];

View File

@@ -15,7 +15,7 @@ import { customerProductToLineItems } from "@/internal/billing/v2/utils/lineItem
const formatLineItem = (item: LineItem) => ({
description: item.description,
amount: item.amount,
finalAmount: item.finalAmount,
amountAfterDiscounts: item.amountAfterDiscounts,
});
const logSharedSubscriptionTrialLineItems = ({
@@ -30,7 +30,7 @@ const logSharedSubscriptionTrialLineItems = ({
lineItems: LineItem[];
}) => {
const formatLineItemCompact = (item: LineItem) =>
` ${item.description}: ${chalk.yellow(item.finalAmount.toFixed(2))}`;
` ${item.description}: ${chalk.yellow(item.amountAfterDiscounts.toFixed(2))}`;
// Structured info log
// logger.info(`buildSharedSubscriptionTrialLineItems data`, {

View File

@@ -5,7 +5,7 @@ import type { Logger } from "@/external/logtail/logtailUtils";
const formatLineItem = (item: LineItem) => ({
description: item.description,
amount: item.amount,
finalAmount: item.finalAmount,
amountAfterDiscounts: item.amountAfterDiscounts,
});
export const logBuildAutumnLineItems = ({
@@ -19,7 +19,7 @@ export const logBuildAutumnLineItems = ({
}) => {
// Debug output (compact table format)
const formatLineItemCompact = (item: LineItem) =>
` ${item.description}: ${chalk.yellow(item.finalAmount.toFixed(2))}`;
` ${item.description}: ${chalk.yellow(item.amountAfterDiscounts.toFixed(2))}`;
logger.debug("========== [buildAutumnLineItems] ==========");

View File

@@ -16,9 +16,13 @@ import { discountAppliesToLineItem } from "./discountAppliesToLineItem";
export const applyAmountOffDiscountToLineItems = ({
lineItems,
discount,
options = {},
}: {
lineItems: LineItem[];
discount: StripeDiscountWithCoupon;
options?: {
skipDescriptionTag?: boolean;
};
}): LineItem[] => {
const coupon = discount.source.coupon;
const amountOffCents = coupon.amount_off;
@@ -75,18 +79,20 @@ export const applyAmountOffDiscountToLineItems = ({
// Discounts only apply to charges (refunds filtered by discountAppliesToLineItem)
// Cap at 0 to prevent negative charges
const finalAmount = Math.max(
const amountAfterDiscounts = Math.max(
new Decimal(item.amount).minus(totalDiscount).toNumber(),
0,
);
const description = options.skipDescriptionTag
? item.description
: addDiscountTagToDescription({ description: item.description });
return {
...item,
description: addDiscountTagToDescription({
description: item.description,
}),
description,
discounts: [...existingDiscounts, newDiscount],
finalAmount,
amountAfterDiscounts,
};
});
};

View File

@@ -14,9 +14,13 @@ import { discountAppliesToLineItem } from "./discountAppliesToLineItem";
export const applyPercentOffDiscountToLineItems = ({
lineItems,
discount,
options = {},
}: {
lineItems: LineItem[];
discount: StripeDiscountWithCoupon;
options?: {
skipDescriptionTag?: boolean;
};
}): LineItem[] => {
const coupon = discount.source.coupon;
const percentOff = coupon.percent_off;
@@ -31,9 +35,9 @@ export const applyPercentOffDiscountToLineItems = ({
return item;
}
// Use current finalAmount as base for multiplicative stacking
// If no previous discounts, finalAmount equals amount
const currentAmount = item.finalAmount ?? item.amount;
// Use current amountAfterDiscounts as base for multiplicative stacking
// If no previous discounts, amountAfterDiscounts equals amount
const currentAmount = item.amountAfterDiscounts ?? item.amount;
// Calculate discount amount: |currentAmount| * (percentOff / 100)
const itemDiscount = new Decimal(Math.abs(currentAmount))
@@ -55,18 +59,20 @@ export const applyPercentOffDiscountToLineItems = ({
// Discounts only apply to charges (refunds filtered by discountAppliesToLineItem)
// Cap at 0 to prevent negative charges
const finalAmount = Math.max(
const amountAfterDiscounts = Math.max(
new Decimal(currentAmount).minus(itemDiscount).toNumber(),
0,
);
const description = options.skipDescriptionTag
? item.description
: addDiscountTagToDescription({ description: item.description });
return {
...item,
description: addDiscountTagToDescription({
description: item.description,
}),
description,
discounts: [...existingDiscounts, newDiscount],
finalAmount,
amountAfterDiscounts,
};
});
};

View File

@@ -5,9 +5,13 @@ import { applyPercentOffDiscountToLineItems } from "./applyPercentOffDiscountToL
export const applyStripeDiscountsToLineItems = ({
lineItems,
discounts,
options = {},
}: {
lineItems: LineItem[];
discounts: StripeDiscountWithCoupon[];
options?: {
skipDescriptionTag?: boolean;
};
}): LineItem[] => {
const percentOffDiscounts = discounts.filter(
(d) => d.source.coupon.percent_off,
@@ -20,11 +24,16 @@ export const applyStripeDiscountsToLineItems = ({
lineItems = applyPercentOffDiscountToLineItems({
lineItems,
discount,
options,
});
}
for (const discount of amountOffDiscounts) {
lineItems = applyAmountOffDiscountToLineItems({ lineItems, discount });
lineItems = applyAmountOffDiscountToLineItems({
lineItems,
discount,
options,
});
}
return lineItems;

View File

@@ -0,0 +1,42 @@
import type { LineItem } from "@autumn/shared";
import type Stripe from "stripe";
/**
* Converts a LineItem to Stripe metadata for invoice line items.
*
* Metadata includes:
* - autumn_product_id: The Autumn product ID
* - autumn_price_id: The Autumn price ID
* - stripe_product_id: The Stripe product ID (if available)
* - coupon_ids: Comma-separated list of coupon IDs (if discounts applied)
*/
export const lineItemToMetadata = ({
lineItem,
}: {
lineItem: LineItem;
}): Stripe.MetadataParam => {
const { context, discounts } = lineItem;
const { product, price } = context;
const metadata: Stripe.MetadataParam = {
autumn_product_id: product.id,
autumn_price_id: price.id,
};
const stripeProductId = product.processor?.id;
if (stripeProductId) {
metadata.stripe_product_id = stripeProductId;
}
if (discounts.length > 0) {
const couponIds = discounts
.map((d) => d.stripeCouponId)
.filter(Boolean)
.join(",");
if (couponIds) {
metadata.coupon_ids = couponIds;
}
}
return metadata;
};

View File

@@ -1,11 +1,9 @@
import { atmnToStripeAmount, type LineItem, msToSeconds } from "@autumn/shared";
import type Stripe from "stripe";
import { lineItemToMetadata } from "./lineItemToMetadata";
/**
* Converts a single LineItem to Stripe.InvoiceItemCreateParams
*
* Uses effectivePeriod (the actual period being charged/refunded) for Stripe,
* which accounts for mid-cycle changes.
*/
const toStripeCreateInvoiceItemParams = ({
stripeCustomerId,
@@ -18,17 +16,35 @@ const toStripeCreateInvoiceItemParams = ({
stripeInvoiceId?: string;
lineItem: LineItem;
}): Stripe.InvoiceItemCreateParams => {
const { finalAmount, description, context } = lineItem;
const { effectivePeriod, currency } = context;
const { amount, amountAfterDiscounts, description, context } = lineItem;
const { effectivePeriod, currency, discountable } = context;
// If discountable, use amount (let Stripe apply discounts), otherwise use amountAfterDiscounts
const lineAmount = discountable ? amount : amountAfterDiscounts;
const isNegative = lineAmount < 0;
const stripeProductId = context.product.processor?.id ?? "";
const shouldUsePriceData = !isNegative && stripeProductId;
return {
customer: stripeCustomerId,
subscription: stripeSubscriptionId,
invoice: stripeInvoiceId,
amount: atmnToStripeAmount({ amount: finalAmount }),
amount: shouldUsePriceData
? undefined
: atmnToStripeAmount({ amount: lineAmount }),
price_data: shouldUsePriceData
? {
unit_amount: atmnToStripeAmount({ amount: lineAmount }),
currency,
product: stripeProductId,
}
: undefined,
metadata: lineItemToMetadata({ lineItem }),
currency,
description,
discountable: false,
discountable: discountable ?? false,
period: effectivePeriod
? {
start: msToSeconds(effectivePeriod.start),

View File

@@ -1,5 +1,6 @@
import { atmnToStripeAmount, type LineItem, msToSeconds } from "@autumn/shared";
import type Stripe from "stripe";
import { lineItemToMetadata } from "./lineItemToMetadata";
/**
* Converts a single LineItem to Stripe.InvoiceAddLinesParams.Line
@@ -12,13 +13,29 @@ const toStripeAddLineParams = ({
}: {
lineItem: LineItem;
}): Stripe.InvoiceAddLinesParams.Line => {
const { finalAmount, description, context } = lineItem;
const { effectivePeriod } = context;
const { amount, amountAfterDiscounts, description, context } = lineItem;
const { effectivePeriod, currency, discountable } = context;
// If discountable, use amount (let Stripe apply discounts), otherwise use amountAfterDiscounts
const lineAmount = discountable ? amount : amountAfterDiscounts;
const isNegative = lineAmount < 0;
const stripeProductId = context.product.processor?.id ?? "";
const shouldUsePriceData = !isNegative && stripeProductId;
return {
description,
amount: atmnToStripeAmount({ amount: finalAmount }),
discountable: false,
amount: shouldUsePriceData
? undefined
: atmnToStripeAmount({ amount: lineAmount }),
price_data: shouldUsePriceData
? {
unit_amount: atmnToStripeAmount({ amount: lineAmount }),
currency,
product: stripeProductId,
}
: undefined,
metadata: lineItemToMetadata({ lineItem }),
discountable: discountable ?? false,
period: effectivePeriod
? {
start: msToSeconds(effectivePeriod.start),

View File

@@ -21,7 +21,10 @@ export const shouldCreateManualStripeInvoice = ({
if (!stripeSubscription) {
const lineItems = autumnBillingPlan.lineItems;
const totalAmount =
lineItems?.reduce((acc, lineItem) => acc + lineItem.finalAmount, 0) ?? 0;
lineItems?.reduce(
(acc, lineItem) => acc + lineItem.amountAfterDiscounts,
0,
) ?? 0;
return totalAmount !== 0;
}

View File

@@ -63,7 +63,7 @@ export const billingContextToCheckoutResponse = async ({
return {
description: line.description,
amount: line.finalAmount,
amount: line.amountAfterDiscounts,
item: getProductItemResponse({
item: productItem,
features,

View File

@@ -143,7 +143,9 @@ export const billingPlanToNextCyclePreview = ({
);
const previewLineItems = autumnLineItems.map(lineItemToPreviewLineItem);
const total = sumValues(autumnLineItems.map((line) => line.finalAmount));
const total = sumValues(
autumnLineItems.map((line) => line.amountAfterDiscounts),
);
return {
nextCycle: {

View File

@@ -25,6 +25,7 @@ export const customerProductToArrearLineItems = ({
options = {
includePeriodDescription: false,
updateNextResetAt: true,
discountable: false,
},
}: {
ctx: AutumnContext;
@@ -38,6 +39,7 @@ export const customerProductToArrearLineItems = ({
options?: {
includePeriodDescription?: boolean;
updateNextResetAt?: boolean;
discountable?: boolean;
};
}): {
lineItems: LineItem[];
@@ -98,7 +100,10 @@ export const customerProductToArrearLineItems = ({
const lineItem = usagePriceToLineItem({
cusEnt,
context,
options: { includePeriodDescription: options.includePeriodDescription },
options: {
includePeriodDescription: options.includePeriodDescription,
discountable: options.discountable,
},
});
// Only include line items with non-zero amounts

View File

@@ -12,7 +12,7 @@ export const lineItemToPreviewLineItem = (line: LineItem): PreviewLineItem => {
return {
title,
description: line.description,
amount: line.finalAmount,
amount: line.amountAfterDiscounts,
discounts: line.discounts,
is_base: isBase,
total_quantity: line.total_quantity ?? 1,

View File

@@ -57,7 +57,7 @@ export const logAutumnBillingPlan = ({
lineItems:
plan.lineItems?.map(
(item) => `${item.description}: ${item.finalAmount}`,
(item) => `${item.description}: ${item.amountAfterDiscounts}`,
) ?? "none",
},
},

View File

@@ -12,7 +12,7 @@ const formatCustomerProduct = (customerProduct: FullCusProduct) =>
`${customerProduct.product.name} (${customerProduct.product_id}) [${customerProduct.status}]`;
const formatLineItem = (item: LineItem) =>
`${item.description}: ${item.finalAmount} (charge: ${item.chargeImmediately})`;
`${item.description}: ${item.amountAfterDiscounts} (charge: ${item.chargeImmediately})`;
export const logBillingPreview = ({
ctx,

View File

@@ -12,7 +12,7 @@ const formatCustomerProduct = (customerProduct: FullCusProduct) =>
`${customerProduct.product.name} (${customerProduct.product_id}) [${customerProduct.status}]`;
const formatLineItem = (item: LineItem) =>
`${item.description}: ${item.finalAmount} (charge: ${item.chargeImmediately})`;
`${item.description}: ${item.amountAfterDiscounts} (charge: ${item.chargeImmediately})`;
export const logBillingPreview = ({
ctx,

View File

@@ -25,6 +25,7 @@ export const coreUpdateSubscription: TestGroup = {
"billing/update-subscription/invoice/update-with-invoice-basic.test.ts",
"billing/update-subscription/errors/update-errors-basic.test.ts",
"billing/update-subscription/discounts/percent-off-discount.test.ts",
"billing/update-subscription/discounts/discount-applies-to.test.ts",
"billing/update-subscription/billing-behavior/next-cycle-only.test.ts",
"billing/update-subscription/billing-behavior/next-cycle-only-cancel.test.ts",
],

View File

@@ -10,7 +10,7 @@
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { type ApiCustomerV3, atmnToStripeAmount } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
@@ -23,6 +23,7 @@ import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
@@ -452,3 +453,102 @@ test.concurrent(`${chalk.yellowBright("immediate-switch-basic 5: premium to pro
usage: 0,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 6: Invoice line item metadata and price_data
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has free product
* - Upgrade to pro with invoice mode
*
* Expected Result:
* - Invoice line items have correct metadata:
* - autumn_product_id
* - autumn_price_id
* - stripe_product_id (when available)
* - Line items with positive amounts use price_data (when stripe_product_id exists)
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-basic 6: invoice line item metadata and price_data")}`, async () => {
const customerId = "imm-switch-metadata-price-data";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
// Upgrade from pro to premium with invoice mode to get invoice response
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
// invoice: true,
// finalize_invoice: true,
// enable_product_immediately: true,
redirect_mode: "if_required",
});
// Verify invoice was created
expect(result.invoice).toBeDefined();
expect(result.invoice!.stripe_id).toBeDefined();
// Retrieve the Stripe invoice with line items expanded
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice!.stripe_id,
{ expand: ["lines.data"] },
);
// Verify invoice has line items (charge for premium, refund for pro)
expect(stripeInvoice.lines.data.length).toBeGreaterThan(0);
// Check each line item for metadata and price_data
for (const lineItem of stripeInvoice.lines.data) {
const metadata = lineItem.metadata;
// Verify autumn metadata is present
expect(metadata).toBeDefined();
expect(metadata?.autumn_product_id).toBeDefined();
expect(metadata?.autumn_price_id).toBeDefined();
// Verify shouldUsePriceData logic:
// - Positive amounts with stripe_product_id should use price_data
// - This manifests as the line having pricing.price_details with product reference
// - Negative amounts (refunds) should NOT have price_data
if (lineItem.amount > 0 && metadata?.stripe_product_id) {
expect(lineItem.pricing?.price_details).toBeDefined();
expect(lineItem.pricing?.price_details?.product).toBe(
metadata.stripe_product_id,
);
}
}
// Verify the total is correct (upgrade from $20 to $50 = $30 difference)
expect(stripeInvoice.total).toBe(
atmnToStripeAmount({ amount: 30, currency: "usd" }),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
});

View File

@@ -30,7 +30,7 @@ describe(chalk.yellowBright("applyAmountOffDiscountToLineItems"), () => {
});
expect(result).toHaveLength(1);
expect(result[0].finalAmount).toBe(40); // 50 - 10
expect(result[0].amountAfterDiscounts).toBe(40); // 50 - 10
expect(result[0].discounts).toHaveLength(1);
expect(result[0].discounts[0].amountOff).toBe(10);
});
@@ -44,9 +44,9 @@ describe(chalk.yellowBright("applyAmountOffDiscountToLineItems"), () => {
discount,
});
// The discount amount is still recorded as $50, but finalAmount caps at 0
// The discount amount is still recorded as $50, but amountAfterDiscounts caps at 0
expect(result[0].discounts[0].amountOff).toBe(50);
expect(result[0].finalAmount).toBe(0);
expect(result[0].amountAfterDiscounts).toBe(0);
});
test("zero amount_off returns unchanged items", () => {
@@ -58,7 +58,7 @@ describe(chalk.yellowBright("applyAmountOffDiscountToLineItems"), () => {
discount,
});
expect(result[0].finalAmount).toBe(100);
expect(result[0].amountAfterDiscounts).toBe(100);
expect(result[0].discounts).toHaveLength(0);
});
});
@@ -80,9 +80,9 @@ describe(chalk.yellowBright("applyAmountOffDiscountToLineItems"), () => {
// Each item is 50% of total, so each gets $5
expect(result[0].discounts[0].amountOff).toBe(5);
expect(result[0].finalAmount).toBe(45);
expect(result[0].amountAfterDiscounts).toBe(45);
expect(result[1].discounts[0].amountOff).toBe(5);
expect(result[1].finalAmount).toBe(45);
expect(result[1].amountAfterDiscounts).toBe(45);
});
test("distributes $30 proportionally across unequal items", () => {
@@ -100,9 +100,9 @@ describe(chalk.yellowBright("applyAmountOffDiscountToLineItems"), () => {
// First item: 75/100 * 30 = 22.5 → 23 (rounded)
// Second item: 25/100 * 30 = 7.5 → 8 (rounded)
expect(result[0].discounts[0].amountOff).toBe(23);
expect(result[0].finalAmount).toBe(52); // 75 - 23
expect(result[0].amountAfterDiscounts).toBe(52); // 75 - 23
expect(result[1].discounts[0].amountOff).toBe(8);
expect(result[1].finalAmount).toBe(17); // 25 - 8
expect(result[1].amountAfterDiscounts).toBe(17); // 25 - 8
});
},
);
@@ -122,11 +122,11 @@ describe(chalk.yellowBright("applyAmountOffDiscountToLineItems"), () => {
// Charge item gets full $20 (only applicable item)
expect(result[0].discounts[0].amountOff).toBe(20);
expect(result[0].finalAmount).toBe(80); // 100 - 20
expect(result[0].amountAfterDiscounts).toBe(80); // 100 - 20
// Refund item is skipped - discounts don't apply to refunds
expect(result[1].discounts).toHaveLength(0);
expect(result[1].finalAmount).toBe(-50);
expect(result[1].amountAfterDiscounts).toBe(-50);
});
test("distributes within charge group only (multiple charges)", () => {
@@ -143,10 +143,10 @@ describe(chalk.yellowBright("applyAmountOffDiscountToLineItems"), () => {
// First item: 60/100 * 10 = 6
expect(result[0].discounts[0].amountOff).toBe(6);
expect(result[0].finalAmount).toBe(54);
expect(result[0].amountAfterDiscounts).toBe(54);
// Second item: 40/100 * 10 = 4
expect(result[1].discounts[0].amountOff).toBe(4);
expect(result[1].finalAmount).toBe(36);
expect(result[1].amountAfterDiscounts).toBe(36);
});
});
@@ -174,11 +174,11 @@ describe(chalk.yellowBright("applyAmountOffDiscountToLineItems"), () => {
// Only prod_a gets the discount
expect(result[0].discounts).toHaveLength(1);
expect(result[0].discounts[0].amountOff).toBe(20);
expect(result[0].finalAmount).toBe(80);
expect(result[0].amountAfterDiscounts).toBe(80);
// prod_b unchanged
expect(result[1].discounts).toHaveLength(0);
expect(result[1].finalAmount).toBe(100);
expect(result[1].amountAfterDiscounts).toBe(100);
});
test("skips items without stripeProductId when applies_to exists", () => {
@@ -193,7 +193,7 @@ describe(chalk.yellowBright("applyAmountOffDiscountToLineItems"), () => {
});
expect(result[0].discounts).toHaveLength(0);
expect(result[0].finalAmount).toBe(100);
expect(result[0].amountAfterDiscounts).toBe(100);
});
});
@@ -211,7 +211,7 @@ describe(chalk.yellowBright("applyAmountOffDiscountToLineItems"), () => {
});
expect(result[0].discounts[0].amountOff).toBe(25);
expect(result[0].finalAmount).toBe(75);
expect(result[0].amountAfterDiscounts).toBe(75);
});
});
@@ -231,11 +231,11 @@ describe(chalk.yellowBright("applyAmountOffDiscountToLineItems"), () => {
});
// Total discount: 10 + 15 = 25
// finalAmount: 100 - 25 = 75
// amountAfterDiscounts: 100 - 25 = 75
expect(result[0].discounts).toHaveLength(2);
expect(result[0].discounts[0].amountOff).toBe(10);
expect(result[0].discounts[1].amountOff).toBe(15);
expect(result[0].finalAmount).toBe(75);
expect(result[0].amountAfterDiscounts).toBe(75);
});
});
@@ -269,7 +269,7 @@ describe(chalk.yellowBright("applyAmountOffDiscountToLineItems"), () => {
});
expect(result[0].discounts).toHaveLength(0);
expect(result[0].finalAmount).toBe(100);
expect(result[0].amountAfterDiscounts).toBe(100);
});
test("zero amount line items are skipped in distribution", () => {
@@ -288,7 +288,7 @@ describe(chalk.yellowBright("applyAmountOffDiscountToLineItems"), () => {
expect(result[0].discounts).toHaveLength(0);
// Second item gets full $10
expect(result[1].discounts[0].amountOff).toBe(10);
expect(result[1].finalAmount).toBe(90);
expect(result[1].amountAfterDiscounts).toBe(90);
});
});
});

View File

@@ -30,7 +30,7 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => {
});
expect(result).toHaveLength(1);
expect(result[0].finalAmount).toBe(90); // 100 - 10
expect(result[0].amountAfterDiscounts).toBe(90); // 100 - 10
expect(result[0].discounts).toHaveLength(1);
expect(result[0].discounts[0].amountOff).toBe(10);
expect(result[0].discounts[0].percentOff).toBe(10);
@@ -46,12 +46,12 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => {
});
expect(result).toHaveLength(1);
expect(result[0].finalAmount).toBe(100); // 200 - 100
expect(result[0].amountAfterDiscounts).toBe(100); // 200 - 100
expect(result[0].discounts[0].amountOff).toBe(100);
expect(result[0].discounts[0].percentOff).toBe(50);
});
test("100% discount results in zero finalAmount", () => {
test("100% discount results in zero amountAfterDiscounts", () => {
const lineItems = [lineItemFixtures.charge({ amount: 50 })];
const discount = discounts.hundredPercentOff();
@@ -61,7 +61,7 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => {
});
expect(result).toHaveLength(1);
expect(result[0].finalAmount).toBe(0);
expect(result[0].amountAfterDiscounts).toBe(0);
expect(result[0].discounts[0].amountOff).toBe(50);
});
@@ -75,7 +75,7 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => {
});
expect(result).toHaveLength(1);
expect(result[0].finalAmount).toBe(100);
expect(result[0].amountAfterDiscounts).toBe(100);
expect(result[0].discounts).toHaveLength(0);
});
});
@@ -90,8 +90,8 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => {
discount,
});
// Charge: finalAmount = amount - discount = 100 - 20 = 80
expect(result[0].finalAmount).toBe(80);
// Charge: amountAfterDiscounts = amount - discount = 100 - 20 = 80
expect(result[0].amountAfterDiscounts).toBe(80);
});
test("refund direction: discounts do not apply to refunds", () => {
@@ -104,8 +104,8 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => {
});
// Refunds are skipped by discountAppliesToLineItem
// finalAmount stays at -100, no discounts applied
expect(result[0].finalAmount).toBe(-100);
// amountAfterDiscounts stays at -100, no discounts applied
expect(result[0].amountAfterDiscounts).toBe(-100);
expect(result[0].discounts).toHaveLength(0);
});
});
@@ -131,9 +131,9 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => {
discount,
});
expect(result[0].finalAmount).toBe(50); // Discounted
expect(result[0].amountAfterDiscounts).toBe(50); // Discounted
expect(result[0].discounts).toHaveLength(1);
expect(result[1].finalAmount).toBe(100); // Not discounted
expect(result[1].amountAfterDiscounts).toBe(100); // Not discounted
expect(result[1].discounts).toHaveLength(0);
});
@@ -148,7 +148,7 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => {
discount,
});
expect(result[0].finalAmount).toBe(100);
expect(result[0].amountAfterDiscounts).toBe(100);
expect(result[0].discounts).toHaveLength(0);
});
});
@@ -167,9 +167,9 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => {
discount,
});
expect(result[0].finalAmount).toBe(90); // 100 - 10
expect(result[1].finalAmount).toBe(180); // 200 - 20
expect(result[2].finalAmount).toBe(45); // 50 - 5
expect(result[0].amountAfterDiscounts).toBe(90); // 100 - 10
expect(result[1].amountAfterDiscounts).toBe(180); // 200 - 20
expect(result[2].amountAfterDiscounts).toBe(45); // 50 - 5
});
});
@@ -188,10 +188,10 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => {
discount,
});
// Existing discount already reduced finalAmount from 100 to 90
// Existing discount already reduced amountAfterDiscounts from 100 to 90
// New discount: 90 * 20% = 18 (multiplicative stacking)
// finalAmount: 90 - 18 = 72
expect(result[0].finalAmount).toBe(72);
// amountAfterDiscounts: 90 - 18 = 72
expect(result[0].amountAfterDiscounts).toBe(72);
expect(result[0].discounts).toHaveLength(2);
expect(result[0].discounts[0].amountOff).toBe(10);
expect(result[0].discounts[1].amountOff).toBe(18);
@@ -222,7 +222,7 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => {
});
expect(result[0].discounts[0].amountOff).toBe(3);
expect(result[0].finalAmount).toBe(30);
expect(result[0].amountAfterDiscounts).toBe(30);
});
test("zero amount line item is skipped", () => {
@@ -235,7 +235,7 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => {
});
// 0 * 50% = 0, so itemDiscount is 0 and item is returned unchanged
expect(result[0].finalAmount).toBe(0);
expect(result[0].amountAfterDiscounts).toBe(0);
expect(result[0].discounts).toHaveLength(0);
});
});

View File

@@ -38,7 +38,7 @@ describe(chalk.yellowBright("applyStripeDiscountsToLineItems"), () => {
});
expect(result).toHaveLength(1);
expect(result[0].finalAmount).toBe(100);
expect(result[0].amountAfterDiscounts).toBe(100);
expect(result[0].discounts).toHaveLength(0);
});
});
@@ -55,7 +55,7 @@ describe(chalk.yellowBright("applyStripeDiscountsToLineItems"), () => {
discounts: discountList,
});
expect(result[0].finalAmount).toBe(75); // 100 - 25
expect(result[0].amountAfterDiscounts).toBe(75); // 100 - 25
expect(result[0].discounts).toHaveLength(1);
expect(result[0].discounts[0].amountOff).toBe(25);
expect(result[0].discounts[0].percentOff).toBe(25);
@@ -70,7 +70,7 @@ describe(chalk.yellowBright("applyStripeDiscountsToLineItems"), () => {
discounts: discountList,
});
expect(result[0].finalAmount).toBe(85); // 100 - 15
expect(result[0].amountAfterDiscounts).toBe(85); // 100 - 15
expect(result[0].discounts).toHaveLength(1);
expect(result[0].discounts[0].amountOff).toBe(15);
});
@@ -90,12 +90,12 @@ describe(chalk.yellowBright("applyStripeDiscountsToLineItems"), () => {
});
// Multiplicative stacking:
// First discount: 100 * 10% = 10, finalAmount = 90
// Second discount: 90 * 20% = 18, finalAmount = 72
// First discount: 100 * 10% = 10, amountAfterDiscounts = 90
// Second discount: 90 * 20% = 18, amountAfterDiscounts = 72
expect(result[0].discounts).toHaveLength(2);
expect(result[0].discounts[0].amountOff).toBe(10);
expect(result[0].discounts[1].amountOff).toBe(18);
expect(result[0].finalAmount).toBe(72);
expect(result[0].amountAfterDiscounts).toBe(72);
});
test("percent then amount discounts stack correctly", () => {
@@ -110,12 +110,12 @@ describe(chalk.yellowBright("applyStripeDiscountsToLineItems"), () => {
discounts: discountList,
});
// First: 100 * 20% = 20, finalAmount = 80
// Second: + $10, total = 30, finalAmount = 70
// First: 100 * 20% = 20, amountAfterDiscounts = 80
// Second: + $10, total = 30, amountAfterDiscounts = 70
expect(result[0].discounts).toHaveLength(2);
expect(result[0].discounts[0].amountOff).toBe(20);
expect(result[0].discounts[1].amountOff).toBe(10);
expect(result[0].finalAmount).toBe(70);
expect(result[0].amountAfterDiscounts).toBe(70);
});
test("percent applied before amount regardless of input order", () => {
@@ -130,14 +130,14 @@ describe(chalk.yellowBright("applyStripeDiscountsToLineItems"), () => {
discounts: discountList,
});
// Percent always applied first: 100 * 20% = 20, finalAmount = 80
// Amount applied second: + $10, total = 30, finalAmount = 70
// Percent always applied first: 100 * 20% = 20, amountAfterDiscounts = 80
// Amount applied second: + $10, total = 30, amountAfterDiscounts = 70
// Discounts array order: percent first, then amount
expect(result[0].discounts).toHaveLength(2);
expect(result[0].discounts[0].amountOff).toBe(20); // percent discount
expect(result[0].discounts[0].percentOff).toBe(20);
expect(result[0].discounts[1].amountOff).toBe(10); // amount discount
expect(result[0].finalAmount).toBe(70);
expect(result[0].amountAfterDiscounts).toBe(70);
});
});
@@ -162,12 +162,12 @@ describe(chalk.yellowBright("applyStripeDiscountsToLineItems"), () => {
expect(result[0].discounts).toHaveLength(2);
expect(result[0].discounts[0].amountOff).toBe(8); // 10%
expect(result[0].discounts[1].amountOff).toBe(8); // $10 * 80%
expect(result[0].finalAmount).toBe(64);
expect(result[0].amountAfterDiscounts).toBe(64);
expect(result[1].discounts).toHaveLength(2);
expect(result[1].discounts[0].amountOff).toBe(2); // 10%
expect(result[1].discounts[1].amountOff).toBe(2); // $10 * 20%
expect(result[1].finalAmount).toBe(16);
expect(result[1].amountAfterDiscounts).toBe(16);
});
});
@@ -194,8 +194,8 @@ describe(chalk.yellowBright("applyStripeDiscountsToLineItems"), () => {
discounts: discountList,
});
expect(result[0].finalAmount).toBe(50); // Discounted
expect(result[1].finalAmount).toBe(100); // Not discounted
expect(result[0].amountAfterDiscounts).toBe(50); // Discounted
expect(result[1].amountAfterDiscounts).toBe(100); // Not discounted
});
test("amount discount applies to restricted products only", () => {
@@ -220,8 +220,8 @@ describe(chalk.yellowBright("applyStripeDiscountsToLineItems"), () => {
discounts: discountList,
});
expect(result[0].finalAmount).toBe(100); // Not discounted
expect(result[1].finalAmount).toBe(80); // Discounted
expect(result[0].amountAfterDiscounts).toBe(100); // Not discounted
expect(result[1].amountAfterDiscounts).toBe(80); // Discounted
});
test("mixed discounts with different applies_to", () => {
@@ -251,11 +251,11 @@ describe(chalk.yellowBright("applyStripeDiscountsToLineItems"), () => {
// prod_a: 20% off = $20, final = 80
expect(result[0].discounts).toHaveLength(1);
expect(result[0].finalAmount).toBe(80);
expect(result[0].amountAfterDiscounts).toBe(80);
// prod_b: $10 off, final = 90
expect(result[1].discounts).toHaveLength(1);
expect(result[1].finalAmount).toBe(90);
expect(result[1].amountAfterDiscounts).toBe(90);
});
});
@@ -273,9 +273,9 @@ describe(chalk.yellowBright("applyStripeDiscountsToLineItems"), () => {
});
// Refunds are skipped by discountAppliesToLineItem
// finalAmount stays at -100, no discounts applied
// amountAfterDiscounts stays at -100, no discounts applied
expect(result[0].discounts).toHaveLength(0);
expect(result[0].finalAmount).toBe(-100);
expect(result[0].amountAfterDiscounts).toBe(-100);
});
});
});

View File

@@ -10,14 +10,14 @@ import type { LineItem } from "@autumn/shared";
* @param direction - "charge" or "refund" (default: inferred from amount sign)
* @param stripeProductId - Optional Stripe product ID for applies_to matching
* @param discounts - Existing discounts on this line item
* @param finalAmount - Override finalAmount (default: same as amount)
* @param amountAfterDiscounts - Override amountAfterDiscounts (default: same as amount)
*/
const create = ({
amount,
direction,
stripeProductId,
discounts = [],
finalAmount,
amountAfterDiscounts,
description = "Test line item",
chargeImmediately = true,
}: {
@@ -25,12 +25,12 @@ const create = ({
direction?: "charge" | "refund";
stripeProductId?: string;
discounts?: LineItem["discounts"];
finalAmount?: number;
amountAfterDiscounts?: number;
description?: string;
chargeImmediately?: boolean;
}): LineItem => ({
amount,
finalAmount: finalAmount ?? amount,
amountAfterDiscounts: amountAfterDiscounts ?? amount,
description,
discounts,
chargeImmediately,
@@ -53,19 +53,19 @@ const charge = ({
amount = 100,
stripeProductId,
discounts = [],
finalAmount,
amountAfterDiscounts,
}: {
amount?: number;
stripeProductId?: string;
discounts?: LineItem["discounts"];
finalAmount?: number;
amountAfterDiscounts?: number;
} = {}): LineItem =>
create({
amount: Math.abs(amount),
direction: "charge",
stripeProductId,
discounts,
finalAmount,
amountAfterDiscounts,
});
/**
@@ -76,19 +76,19 @@ const refund = ({
amount = 100,
stripeProductId,
discounts = [],
finalAmount,
amountAfterDiscounts,
}: {
amount?: number;
stripeProductId?: string;
discounts?: LineItem["discounts"];
finalAmount?: number;
amountAfterDiscounts?: number;
} = {}): LineItem =>
create({
amount: -Math.abs(amount),
direction: "refund",
stripeProductId,
discounts,
finalAmount,
amountAfterDiscounts,
});
/**
@@ -128,7 +128,7 @@ const withExistingDiscount = ({
stripeProductId?: string;
}): LineItem => {
const existingDiscount = { amountOff: existingDiscountAmount };
const adjustedFinalAmount =
const adjustedAmountAfterDiscounts =
direction === "charge"
? amount - existingDiscountAmount
: amount + existingDiscountAmount;
@@ -138,7 +138,7 @@ const withExistingDiscount = ({
direction,
stripeProductId,
discounts: [existingDiscount],
finalAmount: adjustedFinalAmount,
amountAfterDiscounts: adjustedAmountAfterDiscounts,
});
};

View File

@@ -13,7 +13,7 @@ export const LineItemSchema = z
amount: z.number(),
discounts: z.array(LineItemDiscountSchema).default([]),
finalAmount: z.number().default(0),
amountAfterDiscounts: z.number().default(0),
description: z.string(),
@@ -35,7 +35,7 @@ export const LineItemSchema = z
.transform((data) => {
return {
...data,
finalAmount: data.amount,
amountAfterDiscounts: data.amount,
};
});

View File

@@ -19,6 +19,7 @@ export const LineItemContextSchema = z.object({
direction: z.enum(["charge", "refund"]),
now: z.number(),
billingTiming: z.enum(["in_arrear", "in_advance"]),
discountable: z.boolean().optional(), // If true, let Stripe auto-apply discounts to this line item
});
export type BillingPeriod = z.infer<typeof BillingPeriodSchema>;

View File

@@ -27,6 +27,7 @@ export const usagePriceToLineItem = ({
shouldProrateOverride?: boolean;
chargeImmediatelyOverride?: boolean;
includePeriodDescription?: boolean;
discountable?: boolean;
};
}) => {
const cusPrice = cusEntToCusPrice({ cusEnt });
@@ -75,6 +76,7 @@ export const usagePriceToLineItem = ({
...context,
price: cusPrice.price,
feature: cusEnt.entitlement.feature,
discountable: options.discountable ?? false,
};
// 3. Generate description
@@ -98,7 +100,7 @@ export const usagePriceToLineItem = ({
options.shouldProrateOverride ?? !isConsumablePrice(price);
return buildLineItem({
context,
context: lineItemContext,
amount,
description,