From ac8d8d6edc897110a8aab8d207c8cd6add9e4c9d Mon Sep 17 00:00:00 2001 From: Liangjun Song <146005915+adai26@users.noreply.github.com> Date: Thu, 21 Nov 2024 11:20:01 +0000 Subject: [PATCH] Merge pull request #21957 from overleaf/ls-compute-immediate-charge-for-subscription-update compute immediate charge for subscription update GitOrigin-RevId: 4e5162660b26e6e9db69827a59aa8e0048fa7d5d --- .../Features/Subscription/RecurlyClient.js | 40 +++++++++--- .../Features/Subscription/RecurlyEntities.js | 21 ++++++- .../Subscription/SubscriptionController.js | 3 +- .../preview-subscription-change/root.tsx | 2 +- .../src/Subscription/RecurlyClientTests.js | 62 +++++++++++++++++++ .../subscription-change-preview.ts | 6 +- 6 files changed, 120 insertions(+), 14 deletions(-) diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index b7ee96b1c4..b094eac3ab 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -14,6 +14,7 @@ const { CreditCardPaymentMethod, RecurlyAddOn, RecurlyPlan, + RecurlyImmediateCharge, } = require('./RecurlyEntities') /** @@ -315,21 +316,42 @@ function subscriptionChangeFromApi(subscription, subscriptionChange) { subscriptionAddOnFromApi ) - let immediateCharge = - subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0 - for (const creditInvoice of subscriptionChange.invoiceCollection - ?.creditInvoices ?? []) { - // The credit invoice totals are already negative - immediateCharge += creditInvoice.total ?? 0 - } - return new RecurlySubscriptionChange({ subscription, nextPlanCode: subscriptionChange.plan.code, nextPlanName: subscriptionChange.plan.name, nextPlanPrice: subscriptionChange.unitAmount, nextAddOns, - immediateCharge, + immediateCharge: computeImmediateCharge(subscriptionChange), + }) +} + +/** + * Compute immediate charge based on invoice collection + * + * @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API + * @return {RecurlyImmediateCharge} + */ +function computeImmediateCharge(subscriptionChange) { + const roundToTwoDecimal = (/** @type {number} */ num) => + Math.round(num * 100) / 100 + let subtotal = + subscriptionChange.invoiceCollection?.chargeInvoice?.subtotal ?? 0 + let tax = subscriptionChange.invoiceCollection?.chargeInvoice?.tax ?? 0 + let total = subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0 + for (const creditInvoice of subscriptionChange.invoiceCollection + ?.creditInvoices ?? []) { + // The credit invoice numbers are already negative + subtotal = roundToTwoDecimal(subtotal + (creditInvoice.subtotal ?? 0)) + total = roundToTwoDecimal(total + (creditInvoice.total ?? 0)) + // Tax rate can be different in credit invoice if a user relocates + tax = roundToTwoDecimal(tax + (creditInvoice.tax ?? 0)) + } + + return new RecurlyImmediateCharge({ + subtotal, + total, + tax, }) } diff --git a/services/web/app/src/Features/Subscription/RecurlyEntities.js b/services/web/app/src/Features/Subscription/RecurlyEntities.js index a80db92321..c04baaee8a 100644 --- a/services/web/app/src/Features/Subscription/RecurlyEntities.js +++ b/services/web/app/src/Features/Subscription/RecurlyEntities.js @@ -252,7 +252,7 @@ class RecurlySubscriptionChange { * @param {string} props.nextPlanName * @param {number} props.nextPlanPrice * @param {RecurlySubscriptionAddOn[]} props.nextAddOns - * @param {number} [props.immediateCharge] + * @param {RecurlyImmediateCharge} [props.immediateCharge] */ constructor(props) { this.subscription = props.subscription @@ -260,7 +260,9 @@ class RecurlySubscriptionChange { this.nextPlanName = props.nextPlanName this.nextPlanPrice = props.nextPlanPrice this.nextAddOns = props.nextAddOns - this.immediateCharge = props.immediateCharge ?? 0 + this.immediateCharge = + props.immediateCharge ?? + new RecurlyImmediateCharge({ subtotal: 0, tax: 0, total: 0 }) this.subtotal = this.nextPlanPrice for (const addOn of this.nextAddOns) { @@ -299,6 +301,20 @@ class CreditCardPaymentMethod { } } +class RecurlyImmediateCharge { + /** + * @param {object} props + * @param {number} props.subtotal + * @param {number} props.tax + * @param {number} props.total + */ + constructor(props) { + this.subtotal = props.subtotal + this.tax = props.tax + this.total = props.total + } +} + /** * An add-on configuration, independent of any subscription */ @@ -350,4 +366,5 @@ module.exports = { RecurlyAddOn, RecurlyPlan, isStandaloneAiAddOnPlanCode, + RecurlyImmediateCharge, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 4f6e3d4287..af5d49a1de 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -915,7 +915,7 @@ function makeChangePreview( return { change: subscriptionChangeDescription, currency: subscription.currency, - immediateCharge: subscriptionChange.immediateCharge, + immediateCharge: { ...subscriptionChange.immediateCharge }, paymentMethod: paymentMethod.toString(), nextPlan: { annual: nextPlan.annual ?? false, @@ -966,6 +966,7 @@ module.exports = { previewAddonPurchase: expressify(previewAddonPurchase), purchaseAddon, removeAddon, + makeChangePreview, promises: { getRecommendedCurrency: _getRecommendedCurrency, getLatamCountryBannerDetails, 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 bcf51e7891..28eb5be9e5 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 @@ -65,7 +65,7 @@ function PreviewSubscriptionChange() { {formatCurrencyLocalized( - preview.immediateCharge, + preview.immediateCharge.total, preview.currency )} diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index 780e8fb606..ae80ee1828 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -98,6 +98,7 @@ describe('RecurlyClient', function () { this.client = client = { getAccount: sinon.stub(), listAccountSubscriptions: sinon.stub(), + previewSubscriptionChange: sinon.stub(), } this.recurly = { errors: recurly.errors, @@ -381,4 +382,65 @@ describe('RecurlyClient', function () { ) }) }) + + describe('previewSubscriptionChange', function () { + describe('compute immediate charge', function () { + it('only has charge invoice', async function () { + this.client.previewSubscriptionChange.resolves({ + plan: { code: 'test_code', name: 'test name' }, + unitAmount: 0, + invoiceCollection: { + chargeInvoice: { + subtotal: 100, + tax: 20, + total: 120, + }, + }, + }) + const { immediateCharge } = + await this.RecurlyClient.promises.previewSubscriptionChange( + new RecurlySubscriptionChangeRequest({ + subscription: this.subscription, + timeframe: 'now', + planCode: 'new-plan', + }) + ) + expect(immediateCharge.subtotal).to.be.equal(100) + expect(immediateCharge.tax).to.be.equal(20) + expect(immediateCharge.total).to.be.equal(120) + }) + + it('credit invoice with imprecise float number', async function () { + this.client.previewSubscriptionChange.resolves({ + plan: { code: 'test_code', name: 'test name' }, + unitAmount: 0, + invoiceCollection: { + chargeInvoice: { + subtotal: 100.3, + tax: 20.3, + total: 120.3, + }, + creditInvoices: [ + { + subtotal: -20.1, + tax: -4.1, + total: -24.1, + }, + ], + }, + }) + const { immediateCharge } = + await this.RecurlyClient.promises.previewSubscriptionChange( + new RecurlySubscriptionChangeRequest({ + subscription: this.subscription, + timeframe: 'now', + planCode: 'new-plan', + }) + ) + expect(immediateCharge.subtotal).to.be.equal(80.2) + expect(immediateCharge.tax).to.be.equal(16.2) + expect(immediateCharge.total).to.be.equal(96.2) + }) + }) + }) }) diff --git a/services/web/types/subscription/subscription-change-preview.ts b/services/web/types/subscription/subscription-change-preview.ts index dab0970151..5e4072b75d 100644 --- a/services/web/types/subscription/subscription-change-preview.ts +++ b/services/web/types/subscription/subscription-change-preview.ts @@ -2,10 +2,14 @@ export type SubscriptionChangePreview = { change: SubscriptionChangeDescription currency: string paymentMethod: string - immediateCharge: number nextPlan: { annual: boolean } + immediateCharge: { + subtotal: number + tax: number + total: number + } nextInvoice: { date: string plan: {