Explorar o código

Merge pull request #24519 from overleaf/kh-add-payment-service-low-delta

[web] add `PaymentService` to `buildUsersSubscriptionViewModel`

GitOrigin-RevId: 543531226bad38d34b225dae28cf00a5e02e5558
Kristina hai 1 ano
pai
achega
7920cd9d3d
Modificáronse 26 ficheiros con 828 adicións e 405 borrados
  1. 81 0
      services/web/app/src/Features/Subscription/PaymentService.js
  2. 84 1
      services/web/app/src/Features/Subscription/RecurlyClient.js
  3. 46 0
      services/web/app/src/Features/Subscription/RecurlyEntities.js
  4. 0 22
      services/web/app/src/Features/Subscription/RecurlyWrapper.js
  5. 60 88
      services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
  6. 2 2
      services/web/frontend/js/features/subscription/components/dashboard/pause-modal.tsx
  7. 1 1
      services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx
  8. 1 2
      services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx
  9. 6 7
      services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx
  10. 2 2
      services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
  11. 1 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/add-ons.tsx
  12. 1 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx
  13. 2 2
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx
  14. 2 2
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx
  15. 1 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-plan-change.tsx
  16. 2 2
      services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx
  17. 1 1
      services/web/frontend/js/features/subscription/components/shared/price-exceptions.tsx
  18. 1 1
      services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx
  19. 1 1
      services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
  20. 3 1
      services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx
  21. 33 107
      services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
  22. 117 0
      services/web/test/unit/src/Subscription/PaymentServiceTests.js
  23. 72 9
      services/web/test/unit/src/Subscription/RecurlyClientTests.js
  24. 291 127
      services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js
  25. 12 21
      services/web/types/subscription/dashboard/subscription.ts
  26. 5 3
      services/web/types/subscription/plan.ts

+ 81 - 0
services/web/app/src/Features/Subscription/PaymentService.js

@@ -0,0 +1,81 @@
+// @ts-check
+
+const RecurlyClient = require('./RecurlyClient.js')
+const logger = require('@overleaf/logger')
+const { callbackify } = require('util')
+
+/**
+ * @import { RecurlySubscription, RecurlyAccount, RecurlyCoupon } from "./RecurlyEntities"
+ * @import { ObjectId } from 'mongodb'
+ */
+
+/**
+ * this represents a subset of the Mongo Subscription record
+ *
+ * @typedef {object} MongoSubscription
+ * @property {ObjectId} admin_id
+ * @property {string} [recurlySubscription_id]
+ */
+
+/**
+ * @typedef {object} PaymentRecord
+ * @property {RecurlySubscription} subscription
+ * @property {RecurlyAccount | null} account
+ * @property {RecurlyCoupon[]} coupons
+ */
+
+/**
+ * Get payment information from our Mongo record
+ *
+ * @param {MongoSubscription} subscription
+ * @return {Promise<PaymentRecord | null>}
+ */
+async function getPaymentFromRecord(subscription) {
+  if (subscription == null) {
+    logger.debug('no subscription provided')
+    return null
+  }
+  const userId = (subscription.admin_id._id || subscription.admin_id).toString()
+  const recurlySubscriptionId = subscription.recurlySubscription_id
+
+  // TODO: handle non-recurly payment records
+  if (recurlySubscriptionId == null || recurlySubscriptionId === '') {
+    logger.debug(
+      { userId },
+      "no recurly subscription id found for user's subscription"
+    )
+    return null
+  }
+
+  const subscriptionResponse = await RecurlyClient.promises.getSubscription(
+    recurlySubscriptionId
+  )
+  if (!subscriptionResponse) {
+    logger.debug(
+      { recurlySubscriptionId },
+      'no recurly subscription found for subscription id'
+    )
+    return null
+  }
+
+  const accountResponse =
+    await RecurlyClient.promises.getAccountForUserId(userId)
+  const accountCoupons =
+    await RecurlyClient.promises.getActiveCouponsForUserId(userId)
+
+  // TODO: include account and coupons in subscription class instead of separately here
+  // if Recurly is removed (Recurly needs 2 extra requests to get account & coupon data)
+  return {
+    subscription: subscriptionResponse,
+    account: accountResponse,
+    coupons: accountCoupons,
+  }
+}
+
+module.exports = {
+  getPaymentFromRecord: callbackify(getPaymentFromRecord),
+
+  promises: {
+    getPaymentFromRecord,
+  },
+}

+ 84 - 1
services/web/app/src/Features/Subscription/RecurlyClient.js

@@ -14,6 +14,8 @@ const {
   CreditCardPaymentMethod,
   CreditCardPaymentMethod,
   RecurlyAddOn,
   RecurlyAddOn,
   RecurlyPlan,
   RecurlyPlan,
+  RecurlyCoupon,
+  RecurlyAccount,
   RecurlyImmediateCharge,
   RecurlyImmediateCharge,
 } = require('./RecurlyEntities')
 } = require('./RecurlyEntities')
 const {
 const {
@@ -31,13 +33,21 @@ const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined
 
 
 const client = new recurly.Client(recurlyApiKey)
 const client = new recurly.Client(recurlyApiKey)
 
 
+/**
+ * Get account for a given user
+ *
+ * @param {string} userId
+ * @return {Promise<RecurlyAccount | null>}
+ */
 async function getAccountForUserId(userId) {
 async function getAccountForUserId(userId) {
   try {
   try {
-    return await client.getAccount(`code-${userId}`)
+    const account = await client.getAccount(`code-${userId}`)
+    return accountFromApi(account)
   } catch (err) {
   } catch (err) {
     if (err instanceof recurly.errors.NotFoundError) {
     if (err instanceof recurly.errors.NotFoundError) {
       // An expected error, we don't need to handle it, just return nothing
       // An expected error, we don't need to handle it, just return nothing
       logger.debug({ userId }, 'no recurly account found for user')
       logger.debug({ userId }, 'no recurly account found for user')
+      return null
     } else {
     } else {
       throw err
       throw err
     }
     }
@@ -62,6 +72,34 @@ async function createAccountForUserId(userId) {
   return account
   return account
 }
 }
 
 
+/**
+ * Get active coupons for a given user
+ *
+ * @param {string} userId
+ * @return {Promise<RecurlyCoupon[]>}
+ */
+async function getActiveCouponsForUserId(userId) {
+  try {
+    const redemptions = await client.listActiveCouponRedemptions(
+      `code-${userId}`
+    )
+
+    const coupons = []
+    for await (const redemption of redemptions.each()) {
+      coupons.push(couponFromApi(redemption))
+    }
+
+    return coupons
+  } catch (err) {
+    // An expected error if no coupons have been redeemed
+    if (err instanceof recurly.errors.NotFoundError) {
+      return []
+    } else {
+      throw err
+    }
+  }
+}
+
 /**
 /**
  * Get a subscription from Recurly
  * Get a subscription from Recurly
  *
  *
@@ -291,6 +329,44 @@ function subscriptionIsCanceledOrExpired(subscription) {
   return state === 'canceled' || state === 'expired'
   return state === 'canceled' || state === 'expired'
 }
 }
 
 
+/**
+ * Build a RecurlyAccount from Recurly API data
+ *
+ * @param {recurly.Account} apiAccount
+ * @return {RecurlyAccount}
+ */
+function accountFromApi(apiAccount) {
+  if (apiAccount.code == null || apiAccount.email == null) {
+    throw new OError('Invalid Recurly account', {
+      account: apiAccount,
+    })
+  }
+  return new RecurlyAccount({
+    code: apiAccount.code,
+    email: apiAccount.email,
+    hasPastDueInvoice: apiAccount.hasPastDueInvoice ?? false,
+  })
+}
+
+/**
+ * Build a RecurlyCoupon from Recurly API data
+ *
+ * @param {recurly.CouponRedemption} apiRedemption
+ * @return {RecurlyCoupon}
+ */
+function couponFromApi(apiRedemption) {
+  if (apiRedemption.coupon == null || apiRedemption.coupon.code == null) {
+    throw new OError('Invalid Recurly coupon', {
+      coupon: apiRedemption,
+    })
+  }
+  return new RecurlyCoupon({
+    code: apiRedemption.coupon.code,
+    name: apiRedemption.coupon.name ?? '',
+    description: apiRedemption.coupon.hostedPageDescription ?? '',
+  })
+}
+
 /**
 /**
  * Build a RecurlySubscription from Recurly API data
  * Build a RecurlySubscription from Recurly API data
  *
  *
@@ -333,6 +409,11 @@ function subscriptionFromApi(apiSubscription) {
     periodStart: apiSubscription.currentPeriodStartedAt,
     periodStart: apiSubscription.currentPeriodStartedAt,
     periodEnd: apiSubscription.currentPeriodEndsAt,
     periodEnd: apiSubscription.currentPeriodEndsAt,
     collectionMethod: apiSubscription.collectionMethod,
     collectionMethod: apiSubscription.collectionMethod,
+    service: 'recurly',
+    state: apiSubscription.state ?? 'active',
+    trialPeriodEnd: apiSubscription.trialEndsAt,
+    pausePeriodStart: apiSubscription.pausedAt,
+    remainingPauseCycles: apiSubscription.remainingPauseCycles,
   })
   })
 
 
   if (apiSubscription.pendingChange != null) {
   if (apiSubscription.pendingChange != null) {
@@ -525,6 +606,7 @@ module.exports = {
 
 
   getAccountForUserId: callbackify(getAccountForUserId),
   getAccountForUserId: callbackify(getAccountForUserId),
   createAccountForUserId: callbackify(createAccountForUserId),
   createAccountForUserId: callbackify(createAccountForUserId),
+  getActiveCouponsForUserId: callbackify(getActiveCouponsForUserId),
   getSubscription: callbackify(getSubscription),
   getSubscription: callbackify(getSubscription),
   getSubscriptionForUser: callbackify(getSubscriptionForUser),
   getSubscriptionForUser: callbackify(getSubscriptionForUser),
   previewSubscriptionChange: callbackify(previewSubscriptionChange),
   previewSubscriptionChange: callbackify(previewSubscriptionChange),
@@ -545,6 +627,7 @@ module.exports = {
     getSubscriptionForUser,
     getSubscriptionForUser,
     getAccountForUserId,
     getAccountForUserId,
     createAccountForUserId,
     createAccountForUserId,
+    getActiveCouponsForUserId,
     previewSubscriptionChange,
     previewSubscriptionChange,
     applySubscriptionChangeRequest,
     applySubscriptionChangeRequest,
     removeSubscriptionChange,
     removeSubscriptionChange,

+ 46 - 0
services/web/app/src/Features/Subscription/RecurlyEntities.js

@@ -27,6 +27,11 @@ class RecurlySubscription {
    * @param {Date} props.periodEnd
    * @param {Date} props.periodEnd
    * @param {string} props.collectionMethod
    * @param {string} props.collectionMethod
    * @param {RecurlySubscriptionChange} [props.pendingChange]
    * @param {RecurlySubscriptionChange} [props.pendingChange]
+   * @param {string} [props.service]
+   * @param {string} [props.state]
+   * @param {Date|null} [props.trialPeriodEnd]
+   * @param {Date|null} [props.pausePeriodStart]
+   * @param {number|null} [props.remainingPauseCycles]
    */
    */
   constructor(props) {
   constructor(props) {
     this.id = props.id
     this.id = props.id
@@ -44,6 +49,11 @@ class RecurlySubscription {
     this.periodEnd = props.periodEnd
     this.periodEnd = props.periodEnd
     this.collectionMethod = props.collectionMethod
     this.collectionMethod = props.collectionMethod
     this.pendingChange = props.pendingChange ?? null
     this.pendingChange = props.pendingChange ?? null
+    this.service = props.service ?? 'recurly'
+    this.state = props.state ?? 'active'
+    this.trialPeriodEnd = props.trialPeriodEnd ?? null
+    this.pausePeriodStart = props.pausePeriodStart ?? null
+    this.remainingPauseCycles = props.remainingPauseCycles ?? null
   }
   }
 
 
   /**
   /**
@@ -424,6 +434,40 @@ class RecurlyPlan {
   }
   }
 }
 }
 
 
+/**
+ * A coupon in the payment provider
+ */
+class RecurlyCoupon {
+  /**
+   * @param {object} props
+   * @param {string} props.code
+   * @param {string} props.name
+   * @param {string} props.description
+   */
+  constructor(props) {
+    this.code = props.code
+    this.name = props.name
+    this.description = props.description
+  }
+}
+
+/**
+ * An account in the payment provider
+ */
+class RecurlyAccount {
+  /**
+   * @param {object} props
+   * @param {string} props.code
+   * @param {string} props.email
+   * @param {boolean} props.hasPastDueInvoice
+   */
+  constructor(props) {
+    this.code = props.code
+    this.email = props.email
+    this.hasPastDueInvoice = props.hasPastDueInvoice ?? false
+  }
+}
+
 /**
 /**
  * Returns whether the given plan code is a standalone AI plan
  * Returns whether the given plan code is a standalone AI plan
  *
  *
@@ -446,6 +490,8 @@ module.exports = {
   CreditCardPaymentMethod,
   CreditCardPaymentMethod,
   RecurlyAddOn,
   RecurlyAddOn,
   RecurlyPlan,
   RecurlyPlan,
+  RecurlyCoupon,
+  RecurlyAccount,
   isStandaloneAiAddOnPlanCode,
   isStandaloneAiAddOnPlanCode,
   RecurlyImmediateCharge,
   RecurlyImmediateCharge,
 }
 }

+ 0 - 22
services/web/app/src/Features/Subscription/RecurlyWrapper.js

@@ -547,27 +547,6 @@ const promises = {
 
 
   updateAccountEmailAddress,
   updateAccountEmailAddress,
 
 
-  async getAccountActiveCoupons(accountId) {
-    const { body } = await RecurlyWrapper.promises.apiRequest({
-      url: `accounts/${accountId}/redemptions`,
-    })
-
-    const redemptions = await RecurlyWrapper.promises._parseRedemptionsXml(body)
-
-    const activeRedemptions = redemptions.filter(
-      redemption => redemption.state === 'active'
-    )
-    const couponCodes = activeRedemptions.map(
-      redemption => redemption.coupon_code
-    )
-
-    return await Promise.all(
-      couponCodes.map(couponCode =>
-        RecurlyWrapper.promises.getCoupon(couponCode)
-      )
-    )
-  },
-
   async getCoupon(couponCode) {
   async getCoupon(couponCode) {
     const opts = { url: `coupons/${couponCode}` }
     const opts = { url: `coupons/${couponCode}` }
     const { body } = await RecurlyWrapper.promises.apiRequest(opts)
     const { body } = await RecurlyWrapper.promises.apiRequest(opts)
@@ -904,7 +883,6 @@ const RecurlyWrapper = {
   _buildXml,
   _buildXml,
   _parseXml: callbackify(promises._parseXml),
   _parseXml: callbackify(promises._parseXml),
   createFixedAmountCoupon: callbackify(promises.createFixedAmountCoupon),
   createFixedAmountCoupon: callbackify(promises.createFixedAmountCoupon),
-  getAccountActiveCoupons: callbackify(promises.getAccountActiveCoupons),
   getBillingInfo: callbackify(promises.getBillingInfo),
   getBillingInfo: callbackify(promises.getBillingInfo),
   getPaginatedEndpoint: callbackify(promises.getPaginatedEndpoint),
   getPaginatedEndpoint: callbackify(promises.getPaginatedEndpoint),
   getSubscription: callbackify(promises.getSubscription),
   getSubscription: callbackify(promises.getSubscription),

+ 60 - 88
services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js

@@ -23,6 +23,8 @@ const {
   V1ConnectionError,
   V1ConnectionError,
 } = require('../Errors/Errors')
 } = require('../Errors/Errors')
 const FeaturesHelper = require('./FeaturesHelper')
 const FeaturesHelper = require('./FeaturesHelper')
+const PaymentService = require('./PaymentService')
+const { formatCurrency } = require('../../util/currency')
 
 
 /**
 /**
  * @import { Subscription } from "../../../../types/project/dashboard/subscription"
  * @import { Subscription } from "../../../../types/project/dashboard/subscription"
@@ -80,38 +82,16 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
     currentInstitutionsWithLicence,
     currentInstitutionsWithLicence,
     managedInstitutions,
     managedInstitutions,
     managedPublishers,
     managedPublishers,
-    recurlySubscription,
-    recurlyCoupons,
+    paymentRecord,
     plan,
     plan,
   } = await async.auto({
   } = await async.auto({
     personalSubscription(cb) {
     personalSubscription(cb) {
       SubscriptionLocator.getUsersSubscription(user, cb)
       SubscriptionLocator.getUsersSubscription(user, cb)
     },
     },
-    recurlySubscription: [
+    paymentRecord: [
       'personalSubscription',
       'personalSubscription',
       ({ personalSubscription }, cb) => {
       ({ personalSubscription }, cb) => {
-        if (
-          personalSubscription == null ||
-          personalSubscription.recurlySubscription_id == null ||
-          personalSubscription.recurlySubscription_id === ''
-        ) {
-          return cb(null, null)
-        }
-        RecurlyWrapper.getSubscription(
-          personalSubscription.recurlySubscription_id,
-          { includeAccount: true },
-          cb
-        )
-      },
-    ],
-    recurlyCoupons: [
-      'recurlySubscription',
-      ({ recurlySubscription }, cb) => {
-        if (!recurlySubscription) {
-          return cb(null, null)
-        }
-        const accountId = recurlySubscription.account.account_code
-        RecurlyWrapper.getAccountActiveCoupons(accountId, cb)
+        PaymentService.getPaymentFromRecord(personalSubscription, cb)
       },
       },
     ],
     ],
     plan: [
     plan: [
@@ -158,6 +138,8 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
     },
     },
   })
   })
 
 
+  const recurlySubscription = paymentRecord && paymentRecord.subscription
+
   if (memberGroupSubscriptions == null) {
   if (memberGroupSubscriptions == null) {
     memberGroupSubscriptions = []
     memberGroupSubscriptions = []
   } else {
   } else {
@@ -216,9 +198,6 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
   if (managedInstitutions == null) {
   if (managedInstitutions == null) {
     managedInstitutions = []
     managedInstitutions = []
   }
   }
-  if (recurlyCoupons == null) {
-    recurlyCoupons = []
-  }
 
 
   personalSubscription = serializeMongooseObject(personalSubscription)
   personalSubscription = serializeMongooseObject(personalSubscription)
 
 
@@ -251,8 +230,8 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
     // The MEMBERS_LIMIT_ADD_ON_CODE is considered as part of the new plan model
     // The MEMBERS_LIMIT_ADD_ON_CODE is considered as part of the new plan model
     const allAddOnsPriceInCentsExceptAdditionalLicensePrice = addOns.reduce(
     const allAddOnsPriceInCentsExceptAdditionalLicensePrice = addOns.reduce(
       (prev, curr) => {
       (prev, curr) => {
-        return curr.add_on_code !== MEMBERS_LIMIT_ADD_ON_CODE
-          ? curr.quantity * curr.unit_amount_in_cents + prev
+        return curr.code !== MEMBERS_LIMIT_ADD_ON_CODE
+          ? curr.quantity * curr.unitPrice + prev
           : prev
           : prev
       },
       },
       0
       0
@@ -261,7 +240,7 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
       allAddOnsPriceInCentsExceptAdditionalLicensePrice +
       allAddOnsPriceInCentsExceptAdditionalLicensePrice +
       allAddOnsPriceInCentsExceptAdditionalLicensePrice * taxRate
       allAddOnsPriceInCentsExceptAdditionalLicensePrice * taxRate
 
 
-    return SubscriptionFormatters.formatPriceLocalized(
+    return formatCurrency(
       totalPlanPriceInCents -
       totalPlanPriceInCents -
         allAddOnsTotalPriceInCentsExceptAdditionalLicensePrice,
         allAddOnsTotalPriceInCentsExceptAdditionalLicensePrice,
       recurlySubscription.currency,
       recurlySubscription.currency,
@@ -271,12 +250,12 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
 
 
   function getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns = []) {
   function getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns = []) {
     return addOns.reduce((prev, curr) => {
     return addOns.reduce((prev, curr) => {
-      if (curr.add_on_code !== MEMBERS_LIMIT_ADD_ON_CODE) {
-        const priceInCents = curr.quantity * curr.unit_amount_in_cents
+      if (curr.code !== MEMBERS_LIMIT_ADD_ON_CODE) {
+        const priceInCents = curr.quantity * curr.unitPrice
         const totalPriceInCents = priceInCents + priceInCents * taxRate
         const totalPriceInCents = priceInCents + priceInCents * taxRate
 
 
         if (totalPriceInCents > 0) {
         if (totalPriceInCents > 0) {
-          prev[curr.add_on_code] = SubscriptionFormatters.formatPriceLocalized(
+          prev[curr.code] = formatCurrency(
             totalPriceInCents,
             totalPriceInCents,
             recurlySubscription.currency,
             recurlySubscription.currency,
             locale
             locale
@@ -289,19 +268,17 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
   }
   }
 
 
   if (personalSubscription && recurlySubscription) {
   if (personalSubscription && recurlySubscription) {
-    const tax = recurlySubscription.tax_in_cents || 0
+    const tax = recurlySubscription.taxAmount || 0
     // Some plans allow adding more seats than the base plan provides.
     // Some plans allow adding more seats than the base plan provides.
     // This is recorded as a subscription add on.
     // This is recorded as a subscription add on.
-    // Note: tax_in_cents already includes the tax for any addon.
+    // Note: taxAmount already includes the tax for any addon.
     let addOnPrice = 0
     let addOnPrice = 0
     let additionalLicenses = 0
     let additionalLicenses = 0
-    const addOns = recurlySubscription.subscription_add_ons || []
-    const taxRate = recurlySubscription.tax_rate
-      ? parseFloat(recurlySubscription.tax_rate._)
-      : 0
+    const addOns = recurlySubscription.addOns || []
+    const taxRate = recurlySubscription.taxRate
     addOns.forEach(addOn => {
     addOns.forEach(addOn => {
-      addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
-      if (addOn.add_on_code === plan.membersLimitAddOn) {
+      addOnPrice += addOn.quantity * addOn.unitPrice
+      if (addOn.code === plan.membersLimitAddOn) {
         additionalLicenses += addOn.quantity
         additionalLicenses += addOn.quantity
       }
       }
     })
     })
@@ -315,40 +292,38 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
       addOns,
       addOns,
       totalLicenses,
       totalLicenses,
       nextPaymentDueAt: SubscriptionFormatters.formatDateTime(
       nextPaymentDueAt: SubscriptionFormatters.formatDateTime(
-        recurlySubscription.current_period_ends_at
+        recurlySubscription.periodEnd
       ),
       ),
       nextPaymentDueDate: SubscriptionFormatters.formatDate(
       nextPaymentDueDate: SubscriptionFormatters.formatDate(
-        recurlySubscription.current_period_ends_at
+        recurlySubscription.periodEnd
       ),
       ),
       currency: recurlySubscription.currency,
       currency: recurlySubscription.currency,
       state: recurlySubscription.state,
       state: recurlySubscription.state,
       trialEndsAtFormatted: SubscriptionFormatters.formatDateTime(
       trialEndsAtFormatted: SubscriptionFormatters.formatDateTime(
-        recurlySubscription.trial_ends_at
+        recurlySubscription.trialPeriodEnd
       ),
       ),
-      trial_ends_at: recurlySubscription.trial_ends_at,
-      activeCoupons: recurlyCoupons,
-      account: recurlySubscription.account,
-      pausedAt: recurlySubscription.paused_at,
-      remainingPauseCycles: recurlySubscription.remaining_pause_cycles,
+      trialEndsAt: recurlySubscription.trialPeriodEnd,
+      activeCoupons: paymentRecord.coupons,
+      accountEmail: paymentRecord.account.email,
+      hasPastDueInvoice: paymentRecord.account.hasPastDueInvoice,
+      pausedAt: recurlySubscription.pausePeriodStart,
+      remainingPauseCycles: recurlySubscription.remainingPauseCycles,
     }
     }
-    if (recurlySubscription.pending_subscription) {
-      const pendingPlan = PlansLocator.findLocalPlanInSettings(
-        recurlySubscription.pending_subscription.plan.plan_code
-      )
+    if (recurlySubscription.pendingChange) {
+      const pendingPlanCode = recurlySubscription.pendingChange.nextPlanCode
+      const pendingPlan = PlansLocator.findLocalPlanInSettings(pendingPlanCode)
       if (pendingPlan == null) {
       if (pendingPlan == null) {
-        throw new Error(
-          `No plan found for planCode '${personalSubscription.planCode}'`
-        )
+        throw new Error(`No plan found for planCode '${pendingPlanCode}'`)
       }
       }
       let pendingAdditionalLicenses = 0
       let pendingAdditionalLicenses = 0
       let pendingAddOnTax = 0
       let pendingAddOnTax = 0
       let pendingAddOnPrice = 0
       let pendingAddOnPrice = 0
-      if (recurlySubscription.pending_subscription.subscription_add_ons) {
+      if (recurlySubscription.pendingChange.nextAddOns) {
         const pendingRecurlyAddons =
         const pendingRecurlyAddons =
-          recurlySubscription.pending_subscription.subscription_add_ons
+          recurlySubscription.pendingChange.nextAddOns
         pendingRecurlyAddons.forEach(addOn => {
         pendingRecurlyAddons.forEach(addOn => {
-          pendingAddOnPrice += addOn.quantity * addOn.unit_amount_in_cents
-          if (addOn.add_on_code === pendingPlan.membersLimitAddOn) {
+          pendingAddOnPrice += addOn.quantity * addOn.unitPrice
+          if (addOn.code === pendingPlan.membersLimitAddOn) {
             pendingAdditionalLicenses += addOn.quantity
             pendingAdditionalLicenses += addOn.quantity
           }
           }
         })
         })
@@ -359,34 +334,33 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
       }
       }
       const pendingSubscriptionTax =
       const pendingSubscriptionTax =
         personalSubscription.recurly.taxRate *
         personalSubscription.recurly.taxRate *
-        recurlySubscription.pending_subscription.unit_amount_in_cents
-      const totalPriceInCents =
-        recurlySubscription.pending_subscription.unit_amount_in_cents +
+        recurlySubscription.pendingChange.nextPlanPrice
+      const totalPrice =
+        recurlySubscription.pendingChange.nextPlanPrice +
         pendingAddOnPrice +
         pendingAddOnPrice +
         pendingAddOnTax +
         pendingAddOnTax +
         pendingSubscriptionTax
         pendingSubscriptionTax
-      personalSubscription.recurly.displayPrice =
-        SubscriptionFormatters.formatPriceLocalized(
-          totalPriceInCents,
-          recurlySubscription.currency,
-          locale
-        )
-      personalSubscription.recurly.currentPlanDisplayPrice =
-        SubscriptionFormatters.formatPriceLocalized(
-          recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
-          recurlySubscription.currency,
-          locale
-        )
+
+      personalSubscription.recurly.displayPrice = formatCurrency(
+        totalPrice,
+        recurlySubscription.currency,
+        locale
+      )
+      personalSubscription.recurly.currentPlanDisplayPrice = formatCurrency(
+        recurlySubscription.planPrice + addOnPrice + tax,
+        recurlySubscription.currency,
+        locale
+      )
       personalSubscription.recurly.planOnlyDisplayPrice =
       personalSubscription.recurly.planOnlyDisplayPrice =
         getPlanOnlyDisplayPrice(
         getPlanOnlyDisplayPrice(
-          totalPriceInCents,
+          totalPrice,
           taxRate,
           taxRate,
-          recurlySubscription.pending_subscription.subscription_add_ons
+          recurlySubscription.pendingChange.nextAddOns
         )
         )
       personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense =
       personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense =
         getAddOnDisplayPricesWithoutAdditionalLicense(
         getAddOnDisplayPricesWithoutAdditionalLicense(
           taxRate,
           taxRate,
-          recurlySubscription.pending_subscription.subscription_add_ons
+          recurlySubscription.pendingChange.nextAddOns
         )
         )
       const pendingTotalLicenses =
       const pendingTotalLicenses =
         (pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
         (pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
@@ -395,16 +369,14 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
       personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses
       personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses
       personalSubscription.pendingPlan = pendingPlan
       personalSubscription.pendingPlan = pendingPlan
     } else {
     } else {
-      const totalPriceInCents =
-        recurlySubscription.unit_amount_in_cents + addOnPrice + tax
-      personalSubscription.recurly.displayPrice =
-        SubscriptionFormatters.formatPriceLocalized(
-          totalPriceInCents,
-          recurlySubscription.currency,
-          locale
-        )
+      const totalPrice = recurlySubscription.planPrice + addOnPrice + tax
+      personalSubscription.recurly.displayPrice = formatCurrency(
+        totalPrice,
+        recurlySubscription.currency,
+        locale
+      )
       personalSubscription.recurly.planOnlyDisplayPrice =
       personalSubscription.recurly.planOnlyDisplayPrice =
-        getPlanOnlyDisplayPrice(totalPriceInCents, taxRate, addOns)
+        getPlanOnlyDisplayPrice(totalPrice, taxRate, addOns)
       personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense =
       personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense =
         getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns)
         getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns)
     }
     }

+ 2 - 2
services/web/frontend/js/features/subscription/components/dashboard/pause-modal.tsx

@@ -40,8 +40,8 @@ export default function PauseSubscriptionModal() {
       plan_code: subscription?.planCode,
       plan_code: subscription?.planCode,
       is_trial:
       is_trial:
         subscription?.recurly.trialEndsAtFormatted &&
         subscription?.recurly.trialEndsAtFormatted &&
-        subscription?.recurly.trial_ends_at &&
-        new Date(subscription.recurly.trial_ends_at).getTime() > Date.now(),
+        subscription?.recurly.trialEndsAt &&
+        new Date(subscription.recurly.trialEndsAt).getTime() > Date.now(),
     })
     })
     setShowCancellation(true)
     setShowCancellation(true)
   }
   }

+ 1 - 1
services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx

@@ -20,7 +20,7 @@ function PersonalSubscriptionRecurlySyncEmail() {
 
 
   if (!personalSubscription || !('recurly' in personalSubscription)) return null
   if (!personalSubscription || !('recurly' in personalSubscription)) return null
 
 
-  const recurlyEmail = personalSubscription.recurly.account.email
+  const recurlyEmail = personalSubscription.recurly.accountEmail
 
 
   if (!userEmail || recurlyEmail === userEmail) return null
   if (!userEmail || recurlyEmail === userEmail) return null
 
 

+ 1 - 2
services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx

@@ -75,8 +75,7 @@ function PersonalSubscription() {
 
 
   return (
   return (
     <>
     <>
-      {personalSubscription.recurly.account.has_past_due_invoice._ ===
-        'true' && (
+      {personalSubscription.recurly.hasPastDueInvoice && (
         <PastDueSubscriptionAlert subscription={personalSubscription} />
         <PastDueSubscriptionAlert subscription={personalSubscription} />
       )}
       )}
       <PersonalSubscriptionStates
       <PersonalSubscriptionStates

+ 6 - 7
services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx

@@ -171,7 +171,7 @@ export function ActiveSubscriptionNew({
         subscription.pendingPlan.name !== subscription.plan.name && (
         subscription.pendingPlan.name !== subscription.plan.name && (
           <p className="mb-1">{t('want_change_to_apply_before_plan_end')}</p>
           <p className="mb-1">{t('want_change_to_apply_before_plan_end')}</p>
         )}
         )}
-      {isInFreeTrial(subscription.recurly.trial_ends_at) &&
+      {isInFreeTrial(subscription.recurly.trialEndsAt) &&
         subscription.recurly.trialEndsAtFormatted && (
         subscription.recurly.trialEndsAtFormatted && (
           <TrialEnding
           <TrialEnding
             trialEndsAtFormatted={subscription.recurly.trialEndsAtFormatted}
             trialEndsAtFormatted={subscription.recurly.trialEndsAtFormatted}
@@ -301,12 +301,11 @@ function PlanActions({
         <FlexibleGroupLicensingActions subscription={subscription} />
         <FlexibleGroupLicensingActions subscription={subscription} />
       ) : (
       ) : (
         <>
         <>
-          {!hasPendingPause &&
-            subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
-              <OLButton variant="secondary" onClick={handlePlanChange}>
-                {t('change_plan')}
-              </OLButton>
-            )}
+          {!hasPendingPause && !subscription.recurly.hasPastDueInvoice && (
+            <OLButton variant="secondary" onClick={handlePlanChange}>
+              {t('change_plan')}
+            </OLButton>
+          )}
         </>
         </>
       )}
       )}
       {hasPendingPause && (
       {hasPendingPause && (

+ 2 - 2
services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx

@@ -108,7 +108,7 @@ export function ActiveSubscription({
         {!recurlyLoadError &&
         {!recurlyLoadError &&
           !subscription.groupPlan &&
           !subscription.groupPlan &&
           !hasPendingPause &&
           !hasPendingPause &&
-          subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
+          !subscription.recurly.hasPastDueInvoice && (
             <>
             <>
               {' '}
               {' '}
               <OLButton
               <OLButton
@@ -128,7 +128,7 @@ export function ActiveSubscription({
       {(!subscription.pendingPlan ||
       {(!subscription.pendingPlan ||
         subscription.pendingPlan.name === subscription.plan.name) &&
         subscription.pendingPlan.name === subscription.plan.name) &&
         subscription.plan.groupPlan && <ContactSupportToChangeGroupPlan />}
         subscription.plan.groupPlan && <ContactSupportToChangeGroupPlan />}
-      {isInFreeTrial(subscription.recurly.trial_ends_at) &&
+      {isInFreeTrial(subscription.recurly.trialEndsAt) &&
         subscription.recurly.trialEndsAtFormatted && (
         subscription.recurly.trialEndsAtFormatted && (
           <TrialEnding
           <TrialEnding
             trialEndsAtFormatted={subscription.recurly.trialEndsAtFormatted}
             trialEndsAtFormatted={subscription.recurly.trialEndsAtFormatted}

+ 1 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/add-ons.tsx

@@ -126,7 +126,7 @@ function AddOns({
             pendingCancellation={
             pendingCancellation={
               subscription.pendingPlan !== undefined &&
               subscription.pendingPlan !== undefined &&
               (subscription.pendingPlan.addOns ?? []).every(
               (subscription.pendingPlan.addOns ?? []).every(
-                pendingAddOn => pendingAddOn.addOnCode !== addOn.addOnCode
+                pendingAddOn => pendingAddOn.code !== addOn.addOnCode
               )
               )
             }
             }
             displayPrice={addOnsDisplayPrices[addOn.addOnCode]}
             displayPrice={addOnsDisplayPrices[addOn.addOnCode]}

+ 1 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx

@@ -160,7 +160,7 @@ export function CancelSubscription() {
   const showDowngrade = showDowngradeOption(
   const showDowngrade = showDowngradeOption(
     personalSubscription.plan.planCode,
     personalSubscription.plan.planCode,
     personalSubscription.plan.groupPlan,
     personalSubscription.plan.groupPlan,
-    personalSubscription.recurly.trial_ends_at,
+    personalSubscription.recurly.trialEndsAt,
     personalSubscription.recurly.pausedAt,
     personalSubscription.recurly.pausedAt,
     personalSubscription.recurly.remainingPauseCycles
     personalSubscription.recurly.remainingPauseCycles
   )
   )

+ 2 - 2
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx

@@ -17,8 +17,8 @@ export function CancelSubscriptionButton() {
   const subscription = personalSubscription as RecurlySubscription
   const subscription = personalSubscription as RecurlySubscription
   const isInTrial =
   const isInTrial =
     subscription?.recurly.trialEndsAtFormatted &&
     subscription?.recurly.trialEndsAtFormatted &&
-    subscription?.recurly.trial_ends_at &&
-    new Date(subscription.recurly.trial_ends_at).getTime() > Date.now()
+    subscription?.recurly.trialEndsAt &&
+    new Date(subscription.recurly.trialEndsAt).getTime() > Date.now()
   const hasPendingOrActivePause =
   const hasPendingOrActivePause =
     subscription.recurly.state === 'paused' ||
     subscription.recurly.state === 'paused' ||
     (subscription.recurly.state === 'active' &&
     (subscription.recurly.state === 'active' &&

+ 2 - 2
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx

@@ -10,7 +10,7 @@ export function ChangeToGroupPlan() {
   const { handleOpenModal, personalSubscription } =
   const { handleOpenModal, personalSubscription } =
     useSubscriptionDashboardContext()
     useSubscriptionDashboardContext()
 
 
-  // TODO: Better way to get RecurlySubscription/trial_ends_at
+  // TODO: Better way to get RecurlySubscription/trialEndsAt
   const subscription =
   const subscription =
     personalSubscription && 'recurly' in personalSubscription
     personalSubscription && 'recurly' in personalSubscription
       ? (personalSubscription as RecurlySubscription)
       ? (personalSubscription as RecurlySubscription)
@@ -25,7 +25,7 @@ export function ChangeToGroupPlan() {
       <h2 style={{ marginTop: 0 }}>{t('looking_multiple_licenses')}</h2>
       <h2 style={{ marginTop: 0 }}>{t('looking_multiple_licenses')}</h2>
       <p style={{ margin: 0 }}>{t('reduce_costs_group_licenses')}</p>
       <p style={{ margin: 0 }}>{t('reduce_costs_group_licenses')}</p>
       <br />
       <br />
-      {isInFreeTrial(subscription?.recurly?.trial_ends_at) ? (
+      {isInFreeTrial(subscription?.recurly?.trialEndsAt) ? (
         <>
         <>
           <OLTooltip
           <OLTooltip
             id="disabled-change-to-group-plan"
             id="disabled-change-to-group-plan"

+ 1 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-plan-change.tsx

@@ -18,7 +18,7 @@ export function PendingPlanChange({
 
 
   const pendingAiAddonCancellation =
   const pendingAiAddonCancellation =
     hasAiAddon &&
     hasAiAddon &&
-    !pendingPlan.addOns?.some(addOn => addOn.add_on_code === AI_ADD_ON_CODE)
+    !pendingPlan.addOns?.some(addOn => addOn.code === AI_ADD_ON_CODE)
 
 
   const pendingAdditionalLicenses =
   const pendingAdditionalLicenses =
     (subscription.recurly.pendingAdditionalLicenses &&
     (subscription.recurly.pendingAdditionalLicenses &&

+ 2 - 2
services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx

@@ -12,8 +12,8 @@ function SubscriptionRemainder({
 }: SubscriptionRemainderProps) {
 }: SubscriptionRemainderProps) {
   const stillInATrial =
   const stillInATrial =
     subscription.recurly.trialEndsAtFormatted &&
     subscription.recurly.trialEndsAtFormatted &&
-    subscription.recurly.trial_ends_at &&
-    new Date(subscription.recurly.trial_ends_at).getTime() > Date.now()
+    subscription.recurly.trialEndsAt &&
+    new Date(subscription.recurly.trialEndsAt).getTime() > Date.now()
 
 
   const terminationDate = hideTime
   const terminationDate = hideTime
     ? subscription.recurly.nextPaymentDueDate
     ? subscription.recurly.nextPaymentDueDate

+ 1 - 1
services/web/frontend/js/features/subscription/components/shared/price-exceptions.tsx

@@ -19,7 +19,7 @@ export function PriceExceptions({ subscription }: PriceExceptionsProps) {
           <i>* {t('coupons_not_included')}:</i>
           <i>* {t('coupons_not_included')}:</i>
           <ul>
           <ul>
             {activeCoupons.map(coupon => (
             {activeCoupons.map(coupon => (
-              <li key={coupon.id}>
+              <li key={coupon.code}>
                 <i>{coupon.description || coupon.name}</i>
                 <i>{coupon.description || coupon.name}</i>
               </li>
               </li>
             ))}
             ))}

+ 1 - 1
services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx

@@ -37,7 +37,7 @@ function SuccessfulSubscription() {
               type="success"
               type="success"
               content={
               content={
                 <>
                 <>
-                  {subscription.recurly.trial_ends_at && (
+                  {subscription.recurly.trialEndsAt && (
                     <>
                     <>
                       <p>
                       <p>
                         <Trans
                         <Trans

+ 1 - 1
services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx

@@ -208,7 +208,7 @@ describe('<PersonalSubscription />', function () {
       /your billing email address is currently/i
       /your billing email address is currently/i
     ).textContent
     ).textContent
     expect(billingText).to.contain(
     expect(billingText).to.contain(
-      `Your billing email address is currently ${annualActiveSubscription.recurly.account.email}.` +
+      `Your billing email address is currently ${annualActiveSubscription.recurly.accountEmail}.` +
         ` If needed you can update your billing address to ${usersEmail}`
         ` If needed you can update your billing address to ${usersEmail}`
     )
     )
 
 

+ 3 - 1
services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx

@@ -110,7 +110,7 @@ describe('<ActiveSubscription />', function () {
       JSON.parse(JSON.stringify(annualActiveSubscription))
       JSON.parse(JSON.stringify(annualActiveSubscription))
     )
     )
 
 
-    activePastDueSubscription.recurly.account.has_past_due_invoice._ = 'true'
+    activePastDueSubscription.recurly.hasPastDueInvoice = true
 
 
     renderActiveSubscription(activePastDueSubscription)
     renderActiveSubscription(activePastDueSubscription)
 
 
@@ -179,6 +179,8 @@ describe('<ActiveSubscription />', function () {
     subscriptionWithActiveCoupons.recurly.activeCoupons = [
     subscriptionWithActiveCoupons.recurly.activeCoupons = [
       {
       {
         name: 'fake coupon name',
         name: 'fake coupon name',
+        code: 'fake-coupon',
+        description: '',
       },
       },
     ]
     ]
     renderActiveSubscription(subscriptionWithActiveCoupons)
     renderActiveSubscription(subscriptionWithActiveCoupons)

+ 33 - 107
services/web/test/frontend/features/subscription/fixtures/subscriptions.ts

@@ -45,14 +45,10 @@ export const annualActiveSubscription: RecurlySubscription = {
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
-    trial_ends_at: null,
+    trialEndsAt: null,
     activeCoupons: [],
     activeCoupons: [],
-    account: {
-      email: 'fake@example.com',
-      created_at: '2024-12-31T09:40:27.000Z',
-      has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
-      has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
-    },
+    accountEmail: 'fake@example.com',
+    hasPastDueInvoice: false,
     displayPrice: '$199.00',
     displayPrice: '$199.00',
     planOnlyDisplayPrice: '',
     planOnlyDisplayPrice: '',
     addOns: [],
     addOns: [],
@@ -90,14 +86,10 @@ export const annualActiveSubscriptionEuro: RecurlySubscription = {
     currency: 'EUR',
     currency: 'EUR',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
-    trial_ends_at: null,
+    trialEndsAt: null,
     activeCoupons: [],
     activeCoupons: [],
-    account: {
-      email: 'fake@example.com',
-      created_at: '2024-12-31T09:40:27.000Z',
-      has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
-      has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
-    },
+    accountEmail: 'fake@example.com',
+    hasPastDueInvoice: false,
     displayPrice: '€221.96',
     displayPrice: '€221.96',
     planOnlyDisplayPrice: '',
     planOnlyDisplayPrice: '',
     addOns: [],
     addOns: [],
@@ -134,14 +126,10 @@ export const annualActiveSubscriptionPro: RecurlySubscription = {
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
-    trial_ends_at: null,
+    trialEndsAt: null,
     activeCoupons: [],
     activeCoupons: [],
-    account: {
-      email: 'fake@example.com',
-      created_at: '2024-12-31T09:40:27.000Z',
-      has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
-      has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
-    },
+    accountEmail: 'fake@example.com',
+    hasPastDueInvoice: false,
     displayPrice: '$42.00',
     displayPrice: '$42.00',
     planOnlyDisplayPrice: '',
     planOnlyDisplayPrice: '',
     addOns: [],
     addOns: [],
@@ -179,14 +167,10 @@ export const pastDueExpiredSubscription: RecurlySubscription = {
     currency: 'USD',
     currency: 'USD',
     state: 'expired',
     state: 'expired',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
-    trial_ends_at: null,
+    trialEndsAt: null,
     activeCoupons: [],
     activeCoupons: [],
-    account: {
-      email: 'fake@example.com',
-      created_at: '2024-12-31T09:40:27.000Z',
-      has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
-      has_past_due_invoice: { _: 'true', $: { type: 'boolean' } },
-    },
+    accountEmail: 'fake@example.com',
+    hasPastDueInvoice: true,
     displayPrice: '$199.00',
     displayPrice: '$199.00',
     planOnlyDisplayPrice: '',
     planOnlyDisplayPrice: '',
     addOns: [],
     addOns: [],
@@ -224,14 +208,10 @@ export const canceledSubscription: RecurlySubscription = {
     currency: 'USD',
     currency: 'USD',
     state: 'canceled',
     state: 'canceled',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
-    trial_ends_at: null,
+    trialEndsAt: null,
     activeCoupons: [],
     activeCoupons: [],
-    account: {
-      email: 'fake@example.com',
-      created_at: '2024-12-31T09:40:27.000Z',
-      has_canceled_subscription: { _: 'true', $: { type: 'boolean' } },
-      has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
-    },
+    accountEmail: 'fake@example.com',
+    hasPastDueInvoice: false,
     displayPrice: '$199.00',
     displayPrice: '$199.00',
     planOnlyDisplayPrice: '',
     planOnlyDisplayPrice: '',
     addOns: [],
     addOns: [],
@@ -269,14 +249,10 @@ export const pendingSubscriptionChange: RecurlySubscription = {
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
-    trial_ends_at: null,
+    trialEndsAt: null,
     activeCoupons: [],
     activeCoupons: [],
-    account: {
-      email: 'fake@example.com',
-      created_at: '2024-12-31T09:40:27.000Z',
-      has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
-      has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
-    },
+    accountEmail: 'fake@example.com',
+    hasPastDueInvoice: false,
     displayPrice: '$199.00',
     displayPrice: '$199.00',
     planOnlyDisplayPrice: '',
     planOnlyDisplayPrice: '',
     addOns: [],
     addOns: [],
@@ -325,14 +301,10 @@ export const groupActiveSubscription: GroupSubscription = {
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
-    trial_ends_at: null,
+    trialEndsAt: null,
     activeCoupons: [],
     activeCoupons: [],
-    account: {
-      email: 'fake@example.com',
-      created_at: '2024-12-31T09:40:27.000Z',
-      has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
-      has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
-    },
+    accountEmail: 'fake@example.com',
+    hasPastDueInvoice: false,
     displayPrice: '$1290.00',
     displayPrice: '$1290.00',
     planOnlyDisplayPrice: '',
     planOnlyDisplayPrice: '',
     addOns: [],
     addOns: [],
@@ -375,24 +347,10 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
       currency: 'USD',
       currency: 'USD',
       state: 'active',
       state: 'active',
       trialEndsAtFormatted: null,
       trialEndsAtFormatted: null,
-      trial_ends_at: null,
+      trialEndsAt: null,
       activeCoupons: [],
       activeCoupons: [],
-      account: {
-        email: 'fake@example.com',
-        created_at: '2024-12-31T09:40:27.000Z',
-        has_canceled_subscription: {
-          _: 'false',
-          $: {
-            type: 'boolean',
-          },
-        },
-        has_past_due_invoice: {
-          _: 'false',
-          $: {
-            type: 'boolean',
-          },
-        },
-      },
+      accountEmail: 'fake@example.com',
+      hasPastDueInvoice: false,
       displayPrice: '$2967.00',
       displayPrice: '$2967.00',
       currentPlanDisplayPrice: '$2709.00',
       currentPlanDisplayPrice: '$2709.00',
       pendingAdditionalLicenses: 13,
       pendingAdditionalLicenses: 13,
@@ -443,24 +401,10 @@ export const trialSubscription: RecurlySubscription = {
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: sevenDaysFromTodayFormatted,
     trialEndsAtFormatted: sevenDaysFromTodayFormatted,
-    trial_ends_at: new Date(sevenDaysFromToday).toString(),
+    trialEndsAt: new Date(sevenDaysFromToday).toString(),
     activeCoupons: [],
     activeCoupons: [],
-    account: {
-      email: 'fake@example.com',
-      created_at: '2024-12-31T09:40:27.000Z',
-      has_canceled_subscription: {
-        _: 'false',
-        $: {
-          type: 'boolean',
-        },
-      },
-      has_past_due_invoice: {
-        _: 'false',
-        $: {
-          type: 'boolean',
-        },
-      },
-    },
+    accountEmail: 'fake@example.com',
+    hasPastDueInvoice: false,
     displayPrice: '$14.00',
     displayPrice: '$14.00',
     planOnlyDisplayPrice: '',
     planOnlyDisplayPrice: '',
     addOns: [],
     addOns: [],
@@ -519,24 +463,10 @@ export const trialCollaboratorSubscription: RecurlySubscription = {
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: sevenDaysFromTodayFormatted,
     trialEndsAtFormatted: sevenDaysFromTodayFormatted,
-    trial_ends_at: new Date(sevenDaysFromToday).toString(),
+    trialEndsAt: new Date(sevenDaysFromToday).toString(),
     activeCoupons: [],
     activeCoupons: [],
-    account: {
-      email: 'foo@example.com',
-      created_at: '2024-12-31T09:40:27.000Z',
-      has_canceled_subscription: {
-        _: 'false',
-        $: {
-          type: 'boolean',
-        },
-      },
-      has_past_due_invoice: {
-        _: 'false',
-        $: {
-          type: 'boolean',
-        },
-      },
-    },
+    accountEmail: 'foo@example.com',
+    hasPastDueInvoice: false,
     displayPrice: '$21.00',
     displayPrice: '$21.00',
     planOnlyDisplayPrice: '',
     planOnlyDisplayPrice: '',
     addOns: [],
     addOns: [],
@@ -573,14 +503,10 @@ export const monthlyActiveCollaborator: RecurlySubscription = {
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
-    trial_ends_at: null,
+    trialEndsAt: null,
     activeCoupons: [],
     activeCoupons: [],
-    account: {
-      email: 'foo@example.com',
-      created_at: '2024-12-31T09:40:27.000Z',
-      has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
-      has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
-    },
+    accountEmail: 'foo@example.com',
+    hasPastDueInvoice: false,
     displayPrice: '$21.00',
     displayPrice: '$21.00',
     planOnlyDisplayPrice: '',
     planOnlyDisplayPrice: '',
     addOns: [],
     addOns: [],

+ 117 - 0
services/web/test/unit/src/Subscription/PaymentServiceTests.js

@@ -0,0 +1,117 @@
+const sinon = require('sinon')
+const { expect } = require('chai')
+const SandboxedModule = require('sandboxed-module')
+const {
+  RecurlySubscription,
+  RecurlyAccount,
+  RecurlyCoupon,
+} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
+
+const MODULE_PATH = '../../../../app/src/Features/Subscription/PaymentService'
+
+describe('PaymentService', function () {
+  beforeEach(function () {
+    this.user = {
+      _id: '123456',
+    }
+    this.recurlySubscription = new RecurlySubscription({
+      id: 'subscription-id',
+      userId: this.user._id,
+      currency: 'EUR',
+      planCode: 'plan-code',
+      planName: 'plan-name',
+      planPrice: 13,
+      addOns: [],
+      subtotal: 15,
+      taxRate: 0.1,
+      taxAmount: 1.5,
+      total: 14.5,
+      periodStart: new Date(),
+      periodEnd: new Date(),
+      collectionMethod: 'automatic',
+    })
+    this.recurlyAccount = new RecurlyAccount({
+      code: this.user._id,
+      email: 'example@example.com',
+      hasPastDueInvoice: true,
+    })
+    this.recurlyCoupons = [
+      new RecurlyCoupon({
+        code: 'coupon-code',
+        name: 'coupon name',
+        description: 'coupon description',
+      }),
+    ]
+    this.mongoSubscription = {
+      admin_id: this.user,
+      recurlySubscription_id: this.recurlySubscription.id,
+    }
+    this.RecurlyClient = {
+      promises: {
+        getSubscription: sinon.stub(),
+        getAccountForUserId: sinon.stub(),
+        getActiveCouponsForUserId: sinon.stub(),
+      },
+    }
+    return (this.PaymentService = SandboxedModule.require(MODULE_PATH, {
+      requires: {
+        './RecurlyClient': this.RecurlyClient,
+      },
+    }))
+  })
+
+  describe('getPaymentFromRecord', function () {
+    it('should return null for a missing subscription', async function () {
+      const response =
+        await this.PaymentService.promises.getPaymentFromRecord(null)
+      expect(response).to.equal(null)
+    })
+
+    it('should return null if payment service subscription id is missing', async function () {
+      this.mongoSubscription.recurlySubscription_id = null
+      const response = await this.PaymentService.promises.getPaymentFromRecord(
+        this.mongoSubscription
+      )
+      expect(response).to.equal(null)
+    })
+
+    it('should return the subscription', async function () {
+      this.RecurlyClient.promises.getSubscription.returns(
+        this.recurlySubscription
+      )
+      const response = await this.PaymentService.promises.getPaymentFromRecord(
+        this.mongoSubscription
+      )
+      expect(response.subscription).to.deep.equal(this.recurlySubscription)
+    })
+
+    it('should return account information if found', async function () {
+      this.RecurlyClient.promises.getSubscription.returns(
+        this.recurlySubscription
+      )
+      this.RecurlyClient.promises.getAccountForUserId.returns(
+        this.recurlyAccount
+      )
+      const response = await this.PaymentService.promises.getPaymentFromRecord(
+        this.mongoSubscription
+      )
+      expect(response.account.email).to.equal(this.recurlyAccount.email)
+      expect(response.account.hasPastDueInvoice).to.equal(
+        this.recurlyAccount.hasPastDueInvoice
+      )
+    })
+
+    it('should include coupons if found', async function () {
+      this.RecurlyClient.promises.getSubscription.returns(
+        this.recurlySubscription
+      )
+      this.RecurlyClient.promises.getActiveCouponsForUserId.returns(
+        this.recurlyCoupons
+      )
+      const response = await this.PaymentService.promises.getPaymentFromRecord(
+        this.mongoSubscription
+      )
+      expect(response.coupons).to.deep.equal(this.recurlyCoupons)
+    })
+  })
+})

+ 72 - 9
services/web/test/unit/src/Subscription/RecurlyClientTests.js

@@ -6,6 +6,8 @@ const {
   RecurlySubscription,
   RecurlySubscription,
   RecurlySubscriptionChangeRequest,
   RecurlySubscriptionChangeRequest,
   RecurlySubscriptionAddOnUpdate,
   RecurlySubscriptionAddOnUpdate,
+  RecurlyAccount,
+  RecurlyCoupon,
 } = require('../../../../app/src/Features/Subscription/RecurlyEntities')
 } = require('../../../../app/src/Features/Subscription/RecurlyEntities')
 
 
 const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyClient'
 const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyClient'
@@ -26,7 +28,10 @@ describe('RecurlyClient', function () {
     this.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' }
     this.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' }
     this.subscriptionChange = { id: 'subscription-change-123' }
     this.subscriptionChange = { id: 'subscription-change-123' }
     this.recurlyAccount = new recurly.Account()
     this.recurlyAccount = new recurly.Account()
-    Object.assign(this.recurlyAccount, { code: this.user._id })
+    Object.assign(this.recurlyAccount, {
+      code: this.user._id,
+      email: this.user.email,
+    })
 
 
     this.subscriptionAddOn = {
     this.subscriptionAddOn = {
       code: 'addon-code',
       code: 'addon-code',
@@ -101,6 +106,7 @@ describe('RecurlyClient', function () {
       getAccount: sinon.stub(),
       getAccount: sinon.stub(),
       getBillingInfo: sinon.stub(),
       getBillingInfo: sinon.stub(),
       listAccountSubscriptions: sinon.stub(),
       listAccountSubscriptions: sinon.stub(),
+      listActiveCouponRedemptions: sinon.stub(),
       previewSubscriptionChange: sinon.stub(),
       previewSubscriptionChange: sinon.stub(),
     }
     }
     this.recurly = {
     this.recurly = {
@@ -147,20 +153,24 @@ describe('RecurlyClient', function () {
   describe('getAccountForUserId', function () {
   describe('getAccountForUserId', function () {
     it('should return an Account if one exists', async function () {
     it('should return an Account if one exists', async function () {
       this.client.getAccount = sinon.stub().resolves(this.recurlyAccount)
       this.client.getAccount = sinon.stub().resolves(this.recurlyAccount)
-      await expect(
-        this.RecurlyClient.promises.getAccountForUserId(this.user._id)
+      const account = await this.RecurlyClient.promises.getAccountForUserId(
+        this.user._id
       )
       )
-        .to.eventually.be.an.instanceOf(recurly.Account)
-        .that.has.property('code', this.user._id)
+      const expectedAccount = new RecurlyAccount({
+        code: this.user._id,
+        email: this.user.email,
+        hasPastDueInvoice: false,
+      })
+      expect(account).to.deep.equal(expectedAccount)
     })
     })
 
 
-    it('should return nothing if no account found', async function () {
+    it('should return null if no account found', async function () {
       this.client.getAccount = sinon
       this.client.getAccount = sinon
         .stub()
         .stub()
         .throws(new recurly.errors.NotFoundError())
         .throws(new recurly.errors.NotFoundError())
-      expect(
-        this.RecurlyClient.promises.getAccountForUserId('nonsense')
-      ).to.eventually.equal(undefined)
+      const account =
+        await this.RecurlyClient.promises.getAccountForUserId('nonsense')
+      expect(account).to.equal(null)
     })
     })
 
 
     it('should re-throw caught errors', async function () {
     it('should re-throw caught errors', async function () {
@@ -189,6 +199,59 @@ describe('RecurlyClient', function () {
     })
     })
   })
   })
 
 
+  describe('getActiveCouponsForUserId', function () {
+    it('should return an empty array if no coupons returned', async function () {
+      this.client.listActiveCouponRedemptions.returns({
+        each: async function* () {},
+      })
+      const coupons =
+        await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
+      expect(coupons).to.deep.equal([])
+    })
+
+    it('should return a coupons returned by recurly', async function () {
+      const recurlyCoupon = {
+        coupon: {
+          code: 'coupon-code',
+          name: 'Coupon Name',
+          hostedPageDescription: 'hosted page description',
+          invoiceDescription: 'invoice description',
+        },
+      }
+      this.client.listActiveCouponRedemptions.returns({
+        each: async function* () {
+          yield recurlyCoupon
+        },
+      })
+      const coupons =
+        await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
+      const expectedCoupons = [
+        new RecurlyCoupon({
+          code: 'coupon-code',
+          name: 'Coupon Name',
+          description: 'hosted page description',
+        }),
+      ]
+      expect(coupons).to.deep.equal(expectedCoupons)
+    })
+
+    it('should not throw for Recurly not found error', async function () {
+      this.client.listActiveCouponRedemptions = sinon
+        .stub()
+        .throws(new recurly.errors.NotFoundError())
+      const coupons =
+        await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
+      expect(coupons).to.deep.equal([])
+    })
+
+    it('should throw any other API errors', async function () {
+      this.client.listActiveCouponRedemptions = sinon.stub().throws()
+      await expect(
+        this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
+      ).to.eventually.be.rejectedWith(Error)
+    })
+  })
+
   describe('getSubscription', function () {
   describe('getSubscription', function () {
     it('should return the subscription found by recurly', async function () {
     it('should return the subscription found by recurly', async function () {
       this.client.getSubscription = sinon
       this.client.getSubscription = sinon

+ 291 - 127
services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js

@@ -1,6 +1,13 @@
 const SandboxedModule = require('sandboxed-module')
 const SandboxedModule = require('sandboxed-module')
 const sinon = require('sinon')
 const sinon = require('sinon')
 const { assert } = require('chai')
 const { assert } = require('chai')
+const {
+  RecurlyAccount,
+  RecurlySubscription,
+  RecurlySubscriptionAddOn,
+  RecurlySubscriptionChange,
+} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
+
 const modulePath =
 const modulePath =
   '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder'
   '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder'
 
 
@@ -26,6 +33,29 @@ describe('SubscriptionViewModelBuilder', function () {
         state: 'active',
         state: 'active',
       },
       },
     }
     }
+    this.recurlySubscription = new RecurlySubscription({
+      id: this.recurlySubscription_id,
+      userId: this.user._id,
+      currency: 'EUR',
+      planCode: 'plan-code',
+      planName: 'plan-name',
+      planPrice: 13,
+      addOns: [
+        new RecurlySubscriptionAddOn({
+          code: 'addon-code',
+          name: 'addon name',
+          quantity: 1,
+          unitPrice: 2,
+        }),
+      ],
+      subtotal: 15,
+      taxRate: 0.1,
+      taxAmount: 1.5,
+      total: 16.5,
+      periodStart: new Date('2025-01-20T12:00:00.000Z'),
+      periodEnd: new Date('2025-02-20T12:00:00.000Z'),
+      collectionMethod: 'automatic',
+    })
 
 
     this.individualCustomSubscription = {
     this.individualCustomSubscription = {
       planCode: this.planCode,
       planCode: this.planCode,
@@ -42,6 +72,8 @@ describe('SubscriptionViewModelBuilder', function () {
     this.groupPlan = {
     this.groupPlan = {
       planCode: this.groupPlanCode,
       planCode: this.groupPlanCode,
       features: this.groupPlanFeatures,
       features: this.groupPlanFeatures,
+      membersLimit: 4,
+      membersLimitAddOn: 'additional-license',
     }
     }
     this.groupSubscription = {
     this.groupSubscription = {
       planCode: this.groupPlanCode,
       planCode: this.groupPlanCode,
@@ -75,18 +107,29 @@ describe('SubscriptionViewModelBuilder', function () {
         getUsersSubscription: sinon.stub().resolves(),
         getUsersSubscription: sinon.stub().resolves(),
         getMemberSubscriptions: sinon.stub().resolves(),
         getMemberSubscriptions: sinon.stub().resolves(),
       },
       },
+      getUsersSubscription: sinon.stub().yields(),
+      getMemberSubscriptions: sinon.stub().yields(null, []),
+      getManagedGroupSubscriptions: sinon.stub().yields(null, []),
       findLocalPlanInSettings: sinon.stub(),
       findLocalPlanInSettings: sinon.stub(),
     }
     }
     this.InstitutionsGetter = {
     this.InstitutionsGetter = {
       promises: {
       promises: {
         getCurrentInstitutionsWithLicence: sinon.stub().resolves(),
         getCurrentInstitutionsWithLicence: sinon.stub().resolves(),
       },
       },
+      getCurrentInstitutionsWithLicence: sinon.stub().yields(null, []),
+      getManagedInstitutions: sinon.stub().yields(null, []),
     }
     }
     this.InstitutionsManager = {
     this.InstitutionsManager = {
       promises: {
       promises: {
         fetchV1Data: sinon.stub().resolves(),
         fetchV1Data: sinon.stub().resolves(),
       },
       },
     }
     }
+    this.PublishersGetter = {
+      promises: {
+        fetchV1Data: sinon.stub().resolves(),
+      },
+      getManagedPublishers: sinon.stub().yields(null, []),
+    }
     this.RecurlyWrapper = {
     this.RecurlyWrapper = {
       promises: {
       promises: {
         getSubscription: sinon.stub().resolves(),
         getSubscription: sinon.stub().resolves(),
@@ -100,6 +143,9 @@ describe('SubscriptionViewModelBuilder', function () {
     this.PlansLocator = {
     this.PlansLocator = {
       findLocalPlanInSettings: sinon.stub(),
       findLocalPlanInSettings: sinon.stub(),
     }
     }
+    this.PaymentService = {
+      getPaymentFromRecord: sinon.stub().yields(),
+    }
     this.SubscriptionViewModelBuilder = SandboxedModule.require(modulePath, {
     this.SubscriptionViewModelBuilder = SandboxedModule.require(modulePath, {
       requires: {
       requires: {
         '@overleaf/settings': this.Settings,
         '@overleaf/settings': this.Settings,
@@ -109,9 +155,9 @@ describe('SubscriptionViewModelBuilder', function () {
         './RecurlyWrapper': this.RecurlyWrapper,
         './RecurlyWrapper': this.RecurlyWrapper,
         './SubscriptionUpdater': this.SubscriptionUpdater,
         './SubscriptionUpdater': this.SubscriptionUpdater,
         './PlansLocator': this.PlansLocator,
         './PlansLocator': this.PlansLocator,
+        './PaymentService': this.PaymentService,
         './V1SubscriptionManager': {},
         './V1SubscriptionManager': {},
-        './SubscriptionFormatters': {},
-        '../Publishers/PublishersGetter': {},
+        '../Publishers/PublishersGetter': this.PublishersGetter,
         './SubscriptionHelper': {},
         './SubscriptionHelper': {},
       },
       },
     })
     })
@@ -309,152 +355,270 @@ describe('SubscriptionViewModelBuilder', function () {
         plan: this.commonsPlan,
         plan: this.commonsPlan,
       })
       })
     })
     })
-  })
 
 
-  describe('with multiple subscriptions', function () {
-    beforeEach(function () {
-      this.SubscriptionLocator.promises.getUsersSubscription
-        .withArgs(this.user)
-        .resolves(this.individualSubscription)
-      this.SubscriptionLocator.promises.getMemberSubscriptions
-        .withArgs(this.user)
-        .resolves([this.groupSubscription])
-      this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
-        .withArgs(this.user._id)
-        .resolves([this.commonsSubscription])
-    })
+    describe('with multiple subscriptions', function () {
+      beforeEach(function () {
+        this.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(this.user)
+          .resolves(this.individualSubscription)
+        this.SubscriptionLocator.promises.getMemberSubscriptions
+          .withArgs(this.user)
+          .resolves([this.groupSubscription])
+        this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
+          .withArgs(this.user._id)
+          .resolves([this.commonsSubscription])
+      })
 
 
-    it('should return individual when the individual subscription has the best feature set', async function () {
-      this.commonsPlan.features = {
-        compileGroup: 'standard',
-        collaborators: 1,
-        compileTimeout: 60,
-      }
+      it('should return individual when the individual subscription has the best feature set', async function () {
+        this.commonsPlan.features = {
+          compileGroup: 'standard',
+          collaborators: 1,
+          compileTimeout: 60,
+        }
 
 
-      const usersBestSubscription =
-        await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
-          this.user
-        )
+        const usersBestSubscription =
+          await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+            this.user
+          )
 
 
-      assert.deepEqual(usersBestSubscription, {
-        type: 'individual',
-        subscription: this.individualSubscription,
-        plan: this.plan,
-        remainingTrialDays: -1,
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: this.individualSubscription,
+          plan: this.plan,
+          remainingTrialDays: -1,
+        })
       })
       })
-    })
 
 
-    it('should return group when the group subscription has the best feature set', async function () {
-      this.plan.features = {
-        compileGroup: 'standard',
-        collaborators: 1,
-        compileTimeout: 60,
-      }
-      this.commonsPlan.features = {
-        compileGroup: 'standard',
-        collaborators: 1,
-        compileTimeout: 60,
-      }
+      it('should return group when the group subscription has the best feature set', async function () {
+        this.plan.features = {
+          compileGroup: 'standard',
+          collaborators: 1,
+          compileTimeout: 60,
+        }
+        this.commonsPlan.features = {
+          compileGroup: 'standard',
+          collaborators: 1,
+          compileTimeout: 60,
+        }
 
 
-      const usersBestSubscription =
-        await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
-          this.user
-        )
+        const usersBestSubscription =
+          await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+            this.user
+          )
 
 
-      assert.deepEqual(usersBestSubscription, {
-        type: 'group',
-        subscription: {},
-        plan: this.groupPlan,
-        remainingTrialDays: -1,
+        assert.deepEqual(usersBestSubscription, {
+          type: 'group',
+          subscription: {},
+          plan: this.groupPlan,
+          remainingTrialDays: -1,
+        })
       })
       })
-    })
 
 
-    it('should return commons when the commons affiliation has the best feature set', async function () {
-      this.plan.features = {
-        compileGroup: 'priority',
-        collaborators: 5,
-        compileTimeout: 240,
-      }
-      this.groupPlan.features = {
-        compileGroup: 'standard',
-        collaborators: 1,
-        compileTimeout: 60,
-      }
-      this.commonsPlan.features = {
-        compileGroup: 'priority',
-        collaborators: -1,
-        compileTimeout: 240,
-      }
+      it('should return commons when the commons affiliation has the best feature set', async function () {
+        this.plan.features = {
+          compileGroup: 'priority',
+          collaborators: 5,
+          compileTimeout: 240,
+        }
+        this.groupPlan.features = {
+          compileGroup: 'standard',
+          collaborators: 1,
+          compileTimeout: 60,
+        }
+        this.commonsPlan.features = {
+          compileGroup: 'priority',
+          collaborators: -1,
+          compileTimeout: 240,
+        }
 
 
-      const usersBestSubscription =
-        await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
-          this.user
-        )
+        const usersBestSubscription =
+          await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+            this.user
+          )
 
 
-      assert.deepEqual(usersBestSubscription, {
-        type: 'commons',
-        subscription: this.commonsSubscription,
-        plan: this.commonsPlan,
+        assert.deepEqual(usersBestSubscription, {
+          type: 'commons',
+          subscription: this.commonsSubscription,
+          plan: this.commonsPlan,
+        })
       })
       })
-    })
 
 
-    it('should return individual with equal feature sets', async function () {
-      this.plan.features = {
-        compileGroup: 'priority',
-        collaborators: -1,
-        compileTimeout: 240,
-      }
-      this.groupPlan.features = {
-        compileGroup: 'priority',
-        collaborators: -1,
-        compileTimeout: 240,
-      }
-      this.commonsPlan.features = {
-        compileGroup: 'priority',
-        collaborators: -1,
-        compileTimeout: 240,
-      }
+      it('should return individual with equal feature sets', async function () {
+        this.plan.features = {
+          compileGroup: 'priority',
+          collaborators: -1,
+          compileTimeout: 240,
+        }
+        this.groupPlan.features = {
+          compileGroup: 'priority',
+          collaborators: -1,
+          compileTimeout: 240,
+        }
+        this.commonsPlan.features = {
+          compileGroup: 'priority',
+          collaborators: -1,
+          compileTimeout: 240,
+        }
 
 
-      const usersBestSubscription =
-        await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
-          this.user
-        )
+        const usersBestSubscription =
+          await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+            this.user
+          )
 
 
-      assert.deepEqual(usersBestSubscription, {
-        type: 'individual',
-        subscription: this.individualSubscription,
-        plan: this.plan,
-        remainingTrialDays: -1,
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: this.individualSubscription,
+          plan: this.plan,
+          remainingTrialDays: -1,
+        })
       })
       })
-    })
 
 
-    it('should return group over commons with equal feature sets', async function () {
-      this.plan.features = {
-        compileGroup: 'standard',
-        collaborators: 1,
-        compileTimeout: 60,
-      }
-      this.groupPlan.features = {
-        compileGroup: 'priority',
-        collaborators: -1,
-        compileTimeout: 240,
-      }
-      this.commonsPlan.features = {
-        compileGroup: 'priority',
-        collaborators: -1,
-        compileTimeout: 240,
-      }
+      it('should return group over commons with equal feature sets', async function () {
+        this.plan.features = {
+          compileGroup: 'standard',
+          collaborators: 1,
+          compileTimeout: 60,
+        }
+        this.groupPlan.features = {
+          compileGroup: 'priority',
+          collaborators: -1,
+          compileTimeout: 240,
+        }
+        this.commonsPlan.features = {
+          compileGroup: 'priority',
+          collaborators: -1,
+          compileTimeout: 240,
+        }
 
 
-      const usersBestSubscription =
-        await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
-          this.user
+        const usersBestSubscription =
+          await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+            this.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'group',
+          subscription: {},
+          plan: this.groupPlan,
+          remainingTrialDays: -1,
+        })
+      })
+    })
+  })
+
+  describe('buildUsersSubscriptionViewModel', function () {
+    describe('with a recurly subscription', function () {
+      it('adds recurly data to the personal subscription', async function () {
+        this.SubscriptionLocator.getUsersSubscription.yields(
+          null,
+          this.individualSubscription
         )
         )
+        this.PaymentService.getPaymentFromRecord.yields(null, {
+          subscription: this.recurlySubscription,
+          account: new RecurlyAccount({
+            email: 'example@example.com',
+            hasPastDueInvoice: false,
+          }),
+          coupons: [],
+        })
+        const result =
+          await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+            this.user
+          )
+        assert.deepEqual(result.personalSubscription.recurly, {
+          tax: 1.5,
+          taxRate: 0.1,
+          billingDetailsLink: '/user/subscription/recurly/billing-details',
+          accountManagementLink:
+            '/user/subscription/recurly/account-management',
+          additionalLicenses: 0,
+          addOns: [
+            {
+              code: 'addon-code',
+              name: 'addon name',
+              quantity: 1,
+              unitPrice: 2,
+              preTaxTotal: 2,
+            },
+          ],
+          totalLicenses: 0,
+          nextPaymentDueAt: 'February 20th, 2025 12:00 PM UTC',
+          nextPaymentDueDate: 'February 20th, 2025',
+          currency: 'EUR',
+          state: 'active',
+          trialEndsAtFormatted: null,
+          trialEndsAt: null,
+          activeCoupons: [],
+          accountEmail: 'example@example.com',
+          hasPastDueInvoice: false,
+          pausedAt: null,
+          remainingPauseCycles: null,
+          displayPrice: '€16.50',
+          planOnlyDisplayPrice: '€14.30',
+          addOnDisplayPricesWithoutAdditionalLicense: {
+            'addon-code': '€2.20',
+          },
+        })
+      })
 
 
-      assert.deepEqual(usersBestSubscription, {
-        type: 'group',
-        subscription: {},
-        plan: this.groupPlan,
-        remainingTrialDays: -1,
+      it('includes pending changes', async function () {
+        this.SubscriptionLocator.getUsersSubscription.yields(
+          null,
+          this.individualSubscription
+        )
+        this.recurlySubscription.pendingChange = new RecurlySubscriptionChange({
+          subscription: this.recurlySubscription,
+          nextPlanCode: this.groupPlanCode,
+          nextPlanName: 'Group Collaborator (Annual) 4 licenses',
+          nextPlanPrice: 1400,
+          nextAddOns: [
+            new RecurlySubscriptionAddOn({
+              code: 'additional-license',
+              name: 'additional license',
+              quantity: 8,
+              unitPrice: 24.4,
+            }),
+            new RecurlySubscriptionAddOn({
+              code: 'addon-code',
+              name: 'addon name',
+              quantity: 1,
+              unitPrice: 2,
+            }),
+          ],
+        })
+        this.PaymentService.getPaymentFromRecord.yields(null, {
+          subscription: this.recurlySubscription,
+          account: {},
+          coupons: [],
+        })
+        const result =
+          await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+            this.user
+          )
+        assert.equal(
+          result.personalSubscription.recurly.displayPrice,
+          '€1,756.92'
+        )
+        assert.equal(
+          result.personalSubscription.recurly.currentPlanDisplayPrice,
+          '€16.50'
+        )
+        assert.equal(
+          result.personalSubscription.recurly.planOnlyDisplayPrice,
+          '€1,754.72'
+        )
+        assert.deepEqual(
+          result.personalSubscription.recurly
+            .addOnDisplayPricesWithoutAdditionalLicense,
+          { 'addon-code': '€2.20' }
+        )
+        assert.equal(
+          result.personalSubscription.recurly.pendingAdditionalLicenses,
+          8
+        )
+        assert.equal(
+          result.personalSubscription.recurly.pendingTotalLicenses,
+          12
+        )
       })
       })
     })
     })
   })
   })

+ 12 - 21
services/web/types/subscription/dashboard/subscription.ts

@@ -1,6 +1,6 @@
 import { CurrencyCode } from '../currency'
 import { CurrencyCode } from '../currency'
 import { Nullable } from '../../utils'
 import { Nullable } from '../../utils'
-import { Plan, AddOn, RecurlyAddOn } from '../plan'
+import { Plan, AddOn, RecurlyAddOn, PendingRecurlyPlan } from '../plan'
 import { User } from '../../user'
 import { User } from '../../user'
 
 
 type SubscriptionState = 'active' | 'canceled' | 'expired' | 'paused'
 type SubscriptionState = 'active' | 'canceled' | 'expired' | 'paused'
@@ -10,6 +10,12 @@ export type PurchasingAddOnCode = {
   code: string
   code: string
 }
 }
 
 
+type RecurlyCoupon = {
+  code: string
+  name: string
+  description: string
+}
+
 type Recurly = {
 type Recurly = {
   tax: number
   tax: number
   taxRate: number
   taxRate: number
@@ -23,25 +29,10 @@ type Recurly = {
   currency: CurrencyCode
   currency: CurrencyCode
   state?: SubscriptionState
   state?: SubscriptionState
   trialEndsAtFormatted: Nullable<string>
   trialEndsAtFormatted: Nullable<string>
-  trial_ends_at: Nullable<string>
-  activeCoupons: any[] // TODO: confirm type in array
-  account: {
-    email: string
-    created_at: string
-    // data via Recurly API
-    has_canceled_subscription: {
-      _: 'false' | 'true'
-      $: {
-        type: 'boolean'
-      }
-    }
-    has_past_due_invoice: {
-      _: 'false' | 'true'
-      $: {
-        type: 'boolean'
-      }
-    }
-  }
+  trialEndsAt: Nullable<string>
+  activeCoupons: RecurlyCoupon[]
+  accountEmail: string
+  hasPastDueInvoice: boolean
   displayPrice: string
   displayPrice: string
   planOnlyDisplayPrice: string
   planOnlyDisplayPrice: string
   addOnDisplayPricesWithoutAdditionalLicense: Record<string, string>
   addOnDisplayPricesWithoutAdditionalLicense: Record<string, string>
@@ -69,7 +60,7 @@ export type Subscription = {
   planCode: string
   planCode: string
   recurlySubscription_id: string
   recurlySubscription_id: string
   plan: Plan
   plan: Plan
-  pendingPlan?: Plan
+  pendingPlan?: PendingRecurlyPlan
   addOns?: AddOn[]
   addOns?: AddOn[]
 }
 }
 
 

+ 5 - 3
services/web/types/subscription/plan.ts

@@ -24,10 +24,12 @@ export type AddOn = {
 
 
 // add-ons directly accessed through recurly
 // add-ons directly accessed through recurly
 export type RecurlyAddOn = {
 export type RecurlyAddOn = {
-  add_on_code: string
+  code: string
+  name: string
   quantity: number
   quantity: number
-  unit_amount_in_cents: number
-  displayPrice: string
+  unitPrice: number
+  amount?: number
+  displayPrice?: string
 }
 }
 
 
 export type PendingRecurlyPlan = {
 export type PendingRecurlyPlan = {