From eac4a5cb132ba7d9a1963a2d835dc90d488c9505 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 18 Aug 2025 11:33:44 +0200 Subject: [PATCH] Show Recurly's line items breakdown in subscription change preview (#27809) * Show Recurly's line items breakdown in subscription change preview * fix rounding, filter items that cancel each other out GitOrigin-RevId: 0f5d71b3917ce8a52ff36608a6ec6280fe7d38ce --- .../app/src/Features/Subscription/AiHelper.js | 2 +- .../Subscription/PaymentProviderEntities.js | 12 ++++ .../Features/Subscription/RecurlyClient.js | 32 ++++++++++ .../preview-subscription-change/root.tsx | 59 +++++++++++++++---- .../subscription-change-preview.ts | 8 +++ 5 files changed, 101 insertions(+), 12 deletions(-) diff --git a/services/web/app/src/Features/Subscription/AiHelper.js b/services/web/app/src/Features/Subscription/AiHelper.js index 3a383f7b07..d6b0e06644 100644 --- a/services/web/app/src/Features/Subscription/AiHelper.js +++ b/services/web/app/src/Features/Subscription/AiHelper.js @@ -9,7 +9,7 @@ const AI_ADD_ON_CODE = 'assistant' /** * Returns whether the given plan code is a standalone AI plan * - * @param {string} planCode + * @param {string | null | undefined} planCode * @return {boolean} */ function isStandaloneAiAddOnPlanCode(planCode) { diff --git a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js index f13fcc1ec3..69fd046eec 100644 --- a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js +++ b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js @@ -6,6 +6,16 @@ * @import { AddOn } from '../../../../types/subscription/plan' */ +/** + * @typedef {object} ImmediateChargeLineItem + * @property {string | null | undefined} planCode + * @property {string} description + * @property {number} subtotal + * @property {number} discount + * @property {number} tax + * @property {boolean} isAiAssist + */ + const OError = require('@overleaf/o-error') const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors') const PlansLocator = require('./PlansLocator') @@ -539,12 +549,14 @@ class PaymentProviderImmediateCharge { * @param {number} props.tax * @param {number} props.total * @param {number} props.discount + * @param {ImmediateChargeLineItem[]} [props.lineItems] */ constructor(props) { this.subtotal = props.subtotal this.tax = props.tax this.total = props.total this.discount = props.discount + this.lineItems = props.lineItems ?? [] } } diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index d4e02bc702..076b2a2514 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -23,6 +23,7 @@ const { SubtotalLimitExceededError, } = require('./Errors') const RecurlyMetrics = require('./RecurlyMetrics') +const { isStandaloneAiAddOnPlanCode, AI_ADD_ON_CODE } = require('./AiHelper') /** * @import { PaymentProviderSubscriptionChangeRequest } from './PaymentProviderEntities' @@ -585,6 +586,23 @@ function computeImmediateCharge(subscriptionChange) { let total = subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0 let discount = subscriptionChange.invoiceCollection?.chargeInvoice?.discount ?? 0 + + const lineItems = [] + + for (const lineItem of subscriptionChange.invoiceCollection?.chargeInvoice + ?.lineItems || []) { + lineItems.push({ + planCode: lineItem.planCode, + isAiAssist: + lineItem.addOnCode === AI_ADD_ON_CODE || + isStandaloneAiAddOnPlanCode(lineItem.planCode), + description: lineItem.description ?? '', + subtotal: roundToTwoDecimal(lineItem.subtotal ?? 0), + discount: roundToTwoDecimal(lineItem.discount ?? 0), + tax: roundToTwoDecimal(lineItem.tax ?? 0), + }) + } + for (const creditInvoice of subscriptionChange.invoiceCollection ?.creditInvoices ?? []) { // The credit invoice numbers are already negative @@ -593,12 +611,26 @@ function computeImmediateCharge(subscriptionChange) { // Tax rate can be different in credit invoice if a user relocates tax = roundToTwoDecimal(tax + (creditInvoice.tax ?? 0)) discount = roundToTwoDecimal(discount + (creditInvoice.discount ?? 0)) + + for (const lineItem of creditInvoice.lineItems || []) { + lineItems.push({ + planCode: lineItem.planCode, + isAiAssist: + lineItem.addOnCode === AI_ADD_ON_CODE || + isStandaloneAiAddOnPlanCode(lineItem.planCode), + description: lineItem.description ?? '', + subtotal: roundToTwoDecimal(lineItem.subtotal ?? 0), + discount: roundToTwoDecimal(lineItem.discount ?? 0), + tax: roundToTwoDecimal(lineItem.tax ?? 0), + }) + } } return new PaymentProviderImmediateCharge({ subtotal, total, tax, discount, + lineItems, }) } diff --git a/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx b/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx index c73f6eca0e..15652bf5be 100644 --- a/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx +++ b/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx @@ -33,6 +33,22 @@ function PreviewSubscriptionChange() { const location = useLocation() const aiAssistEnabled = useFeatureFlag('overleaf-assist-bundle') + // Filter out items that cancel each other out (AI assist items with subtotals that sum to 0) + const filteredLineItems = preview.immediateCharge.lineItems.filter( + (item, index, arr) => { + if (!item.isAiAssist) return true + + const isCanceledByAnotherItem = arr.some( + (otherItem, otherIndex) => + otherIndex !== index && + otherItem.isAiAssist && + otherItem.subtotal + item.subtotal === 0 + ) + + return !isCanceledByAnotherItem + } + ) + useEffect(() => { if (preview.change.type === 'add-on-purchase') { eventTracking.sendMB('preview-subscription-change-view', { @@ -139,17 +155,38 @@ function PreviewSubscriptionChange() {

{t('due_today')}:

- - {changeName} - - - {formatCurrency( - preview.immediateCharge.subtotal, - preview.currency - )} - - - + {filteredLineItems.length > 1 ? ( + <> + {filteredLineItems.map((item, index) => ( + + + {item.subtotal < 0 + ? `Refund: ${item.description}` + : item.description} + + + + {formatCurrency(item.subtotal, preview.currency)} + + + + ))} + + ) : ( + <> + + {changeName} + + + {formatCurrency( + preview.immediateCharge.subtotal, + preview.currency + )} + + + + + )} {preview.immediateCharge.tax > 0 && ( diff --git a/services/web/types/subscription/subscription-change-preview.ts b/services/web/types/subscription/subscription-change-preview.ts index 5152b80e6e..463c39ade4 100644 --- a/services/web/types/subscription/subscription-change-preview.ts +++ b/services/web/types/subscription/subscription-change-preview.ts @@ -11,6 +11,14 @@ export type SubscriptionChangePreview = { tax: number total: number discount: number + lineItems: { + planCode: string | null | undefined + description: string + subtotal: number + discount: number + tax: number + isAiAssist: boolean + }[] } nextInvoice: { date: string