Merge branch 'feat/link-line-items-to-product' into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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] ==========");
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export const billingContextToCheckoutResponse = async ({
|
||||
|
||||
return {
|
||||
description: line.description,
|
||||
amount: line.finalAmount,
|
||||
amount: line.amountAfterDiscounts,
|
||||
item: getProductItemResponse({
|
||||
item: productItem,
|
||||
features,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -57,7 +57,7 @@ export const logAutumnBillingPlan = ({
|
||||
|
||||
lineItems:
|
||||
plan.lineItems?.map(
|
||||
(item) => `${item.description}: ${item.finalAmount}`,
|
||||
(item) => `${item.description}: ${item.amountAfterDiscounts}`,
|
||||
) ?? "none",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user