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
This commit is contained in:
Domagoj Kriskovic
2025-08-18 11:33:44 +02:00
committed by Copybot
parent 532f9b6549
commit eac4a5cb13
5 changed files with 101 additions and 12 deletions

View File

@@ -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) {

View File

@@ -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 ?? []
}
}

View File

@@ -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,
})
}

View File

@@ -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() {
<OLCard className="payment-summary-card mt-5">
<h3>{t('due_today')}:</h3>
<OLRow>
<OLCol xs={9}>{changeName}</OLCol>
<OLCol xs={3} className="text-end">
<strong>
{formatCurrency(
preview.immediateCharge.subtotal,
preview.currency
)}
</strong>
</OLCol>
</OLRow>
{filteredLineItems.length > 1 ? (
<>
{filteredLineItems.map((item, index) => (
<OLRow key={index}>
<OLCol xs={9}>
{item.subtotal < 0
? `Refund: ${item.description}`
: item.description}
</OLCol>
<OLCol xs={3} className="text-end">
<strong>
{formatCurrency(item.subtotal, preview.currency)}
</strong>
</OLCol>
</OLRow>
))}
</>
) : (
<>
<OLRow>
<OLCol xs={9}>{changeName}</OLCol>
<OLCol xs={3} className="text-end">
<strong>
{formatCurrency(
preview.immediateCharge.subtotal,
preview.currency
)}
</strong>
</OLCol>
</OLRow>
</>
)}
{preview.immediateCharge.tax > 0 && (
<OLRow className="mt-1">

View File

@@ -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