diff --git a/server/src/external/stripe/webhookHandlers/common/eventContextToArrearLineItems.ts b/server/src/external/stripe/webhookHandlers/common/eventContextToArrearLineItems.ts index bf2662467..55a9a2826 100644 --- a/server/src/external/stripe/webhookHandlers/common/eventContextToArrearLineItems.ts +++ b/server/src/external/stripe/webhookHandlers/common/eventContextToArrearLineItems.ts @@ -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 diff --git a/server/src/external/stripe/webhookHandlers/common/logCustomerProductUpdates.ts b/server/src/external/stripe/webhookHandlers/common/logCustomerProductUpdates.ts index 70fd171ab..295b200bc 100644 --- a/server/src/external/stripe/webhookHandlers/common/logCustomerProductUpdates.ts +++ b/server/src/external/stripe/webhookHandlers/common/logCustomerProductUpdates.ts @@ -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({ diff --git a/server/src/external/stripe/webhookHandlers/common/logs/logWebhookArrearLineItems.ts b/server/src/external/stripe/webhookHandlers/common/logs/logWebhookArrearLineItems.ts index ac8c2fe7a..205b6722e 100644 --- a/server/src/external/stripe/webhookHandlers/common/logs/logWebhookArrearLineItems.ts +++ b/server/src/external/stripe/webhookHandlers/common/logs/logWebhookArrearLineItems.ts @@ -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, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts index 5fce3111b..6468ba78e 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts @@ -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 []; diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts index 4d4796c8e..ae5297fb3 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts @@ -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`, { diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/logBuildAutumnLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/logBuildAutumnLineItems.ts index eced1a24d..12d402cb6 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/logBuildAutumnLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/logBuildAutumnLineItems.ts @@ -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] =========="); diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyAmountOffDiscountToLineItems.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyAmountOffDiscountToLineItems.ts index ef1e12f1f..b47077836 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyAmountOffDiscountToLineItems.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyAmountOffDiscountToLineItems.ts @@ -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, }; }); }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyPercentOffDiscountToLineItems.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyPercentOffDiscountToLineItems.ts index dabd27d8e..53b9e2e6e 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyPercentOffDiscountToLineItems.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyPercentOffDiscountToLineItems.ts @@ -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, }; }); }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems.ts index eb19bdb9d..98341fb7b 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems.ts @@ -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; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemToMetadata.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemToMetadata.ts new file mode 100644 index 000000000..1dd69720d --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemToMetadata.ts @@ -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; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToCreateInvoiceItemsParams.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToCreateInvoiceItemsParams.ts index 8de819802..06678c3da 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToCreateInvoiceItemsParams.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToCreateInvoiceItemsParams.ts @@ -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), diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams.ts index e56725280..e46f37694 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams.ts @@ -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), diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/shouldCreateManualStripeInvoice.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/shouldCreateManualStripeInvoice.ts index 6876c3745..a0581eea8 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoices/shouldCreateManualStripeInvoice.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/shouldCreateManualStripeInvoice.ts @@ -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; } diff --git a/server/src/internal/billing/v2/utils/billingContextToCheckoutResponse.ts b/server/src/internal/billing/v2/utils/billingContextToCheckoutResponse.ts index fafe74e2b..6c59de502 100644 --- a/server/src/internal/billing/v2/utils/billingContextToCheckoutResponse.ts +++ b/server/src/internal/billing/v2/utils/billingContextToCheckoutResponse.ts @@ -63,7 +63,7 @@ export const billingContextToCheckoutResponse = async ({ return { description: line.description, - amount: line.finalAmount, + amount: line.amountAfterDiscounts, item: getProductItemResponse({ item: productItem, features, diff --git a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts index bc402efc8..9da4ef643 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts @@ -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: { diff --git a/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts index 896e52ca4..0035c3781 100644 --- a/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts +++ b/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts @@ -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 diff --git a/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts b/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts index 110170c5e..292a7a80b 100644 --- a/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts +++ b/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts @@ -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, diff --git a/server/src/internal/billing/v2/utils/logs/logAutumnBillingPlan.ts b/server/src/internal/billing/v2/utils/logs/logAutumnBillingPlan.ts index adb16d03d..88cee9dc7 100644 --- a/server/src/internal/billing/v2/utils/logs/logAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/utils/logs/logAutumnBillingPlan.ts @@ -57,7 +57,7 @@ export const logAutumnBillingPlan = ({ lineItems: plan.lineItems?.map( - (item) => `${item.description}: ${item.finalAmount}`, + (item) => `${item.description}: ${item.amountAfterDiscounts}`, ) ?? "none", }, }, diff --git a/server/src/internal/billing/v2/utils/logs/logBillingPreview.ts b/server/src/internal/billing/v2/utils/logs/logBillingPreview.ts index bda6fbece..0066c6a9f 100644 --- a/server/src/internal/billing/v2/utils/logs/logBillingPreview.ts +++ b/server/src/internal/billing/v2/utils/logs/logBillingPreview.ts @@ -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, diff --git a/server/src/internal/billing/v2/utils/logs/logNextCyclePreview.ts b/server/src/internal/billing/v2/utils/logs/logNextCyclePreview.ts index ec4f56c06..28315b397 100644 --- a/server/src/internal/billing/v2/utils/logs/logNextCyclePreview.ts +++ b/server/src/internal/billing/v2/utils/logs/logNextCyclePreview.ts @@ -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, diff --git a/server/tests/_groups/core/coreUpdateSubscription.ts b/server/tests/_groups/core/coreUpdateSubscription.ts index 4bc9eb421..de2a68cd7 100644 --- a/server/tests/_groups/core/coreUpdateSubscription.ts +++ b/server/tests/_groups/core/coreUpdateSubscription.ts @@ -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", ], diff --git a/server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts b/server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts index 1a700787b..529024c33 100644 --- a/server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts +++ b/server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.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(customerId); + + // Verify product states + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); +}); diff --git a/server/tests/unit/billing/stripe/discounts/apply-amount-off-discount-to-line-items.spec.ts b/server/tests/unit/billing/stripe/discounts/apply-amount-off-discount-to-line-items.spec.ts index e52ac49ea..af069e1d8 100644 --- a/server/tests/unit/billing/stripe/discounts/apply-amount-off-discount-to-line-items.spec.ts +++ b/server/tests/unit/billing/stripe/discounts/apply-amount-off-discount-to-line-items.spec.ts @@ -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); }); }); }); diff --git a/server/tests/unit/billing/stripe/discounts/apply-percent-off-discount-to-line-items.spec.ts b/server/tests/unit/billing/stripe/discounts/apply-percent-off-discount-to-line-items.spec.ts index 5b3fa316e..3d19abe3c 100644 --- a/server/tests/unit/billing/stripe/discounts/apply-percent-off-discount-to-line-items.spec.ts +++ b/server/tests/unit/billing/stripe/discounts/apply-percent-off-discount-to-line-items.spec.ts @@ -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); }); }); diff --git a/server/tests/unit/billing/stripe/discounts/apply-stripe-discounts-to-line-items.spec.ts b/server/tests/unit/billing/stripe/discounts/apply-stripe-discounts-to-line-items.spec.ts index 4fe7f2a57..4a95ce686 100644 --- a/server/tests/unit/billing/stripe/discounts/apply-stripe-discounts-to-line-items.spec.ts +++ b/server/tests/unit/billing/stripe/discounts/apply-stripe-discounts-to-line-items.spec.ts @@ -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); }); }); }); diff --git a/server/tests/utils/fixtures/db/lineItems.ts b/server/tests/utils/fixtures/db/lineItems.ts index 5fd8f2d06..2bc997bf8 100644 --- a/server/tests/utils/fixtures/db/lineItems.ts +++ b/server/tests/utils/fixtures/db/lineItems.ts @@ -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, }); }; diff --git a/shared/models/billingModels/lineItem/lineItem.ts b/shared/models/billingModels/lineItem/lineItem.ts index 05bb84339..1e7aad19a 100644 --- a/shared/models/billingModels/lineItem/lineItem.ts +++ b/shared/models/billingModels/lineItem/lineItem.ts @@ -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, }; }); diff --git a/shared/models/billingModels/lineItem/lineItemContext.ts b/shared/models/billingModels/lineItem/lineItemContext.ts index 4636cf748..7e769d49b 100644 --- a/shared/models/billingModels/lineItem/lineItemContext.ts +++ b/shared/models/billingModels/lineItem/lineItemContext.ts @@ -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; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts index aa5e9304c..b8360e3a4 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts @@ -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,