Jelajahi Sumber

Merge pull request #21957 from overleaf/ls-compute-immediate-charge-for-subscription-update

compute immediate charge for subscription update

GitOrigin-RevId: 4e5162660b26e6e9db69827a59aa8e0048fa7d5d
Liangjun Song 1 tahun lalu
induk
melakukan
ac8d8d6edc

+ 31 - 9
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,
   })
 }
 

+ 19 - 2
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,
 }

+ 2 - 1
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,

+ 1 - 1
services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx

@@ -65,7 +65,7 @@ function PreviewSubscriptionChange() {
                 <Col xs={3} className="text-right">
                   <strong>
                     {formatCurrencyLocalized(
-                      preview.immediateCharge,
+                      preview.immediateCharge.total,
                       preview.currency
                     )}
                   </strong>

+ 62 - 0
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)
+      })
+    })
+  })
 })

+ 5 - 1
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: {