Files
overleaf-cep/services/web/app/src/Features/Subscription/RecurlyClient.js
ilkin-overleaf 54e439c293 Merge pull request #23203 from overleaf/ii-flexible-group-licensing-no-billing-details
[web] FL handle subscriptions with missing billing info

GitOrigin-RevId: 34209299c039992a80da5739e086beb5d0ede7b0
2025-02-05 09:05:50 +00:00

508 lines
14 KiB
JavaScript

// @ts-check
const recurly = require('recurly')
const Settings = require('@overleaf/settings')
const logger = require('@overleaf/logger')
const OError = require('@overleaf/o-error')
const { callbackify } = require('util')
const UserGetter = require('../User/UserGetter')
const {
RecurlySubscription,
RecurlySubscriptionAddOn,
RecurlySubscriptionChange,
PaypalPaymentMethod,
CreditCardPaymentMethod,
RecurlyAddOn,
RecurlyPlan,
RecurlyImmediateCharge,
} = require('./RecurlyEntities')
const { MissingBillingInfoError } = require('./Errors')
/**
* @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities'
* @import { PaymentMethod } from './types'
*/
const recurlySettings = Settings.apis.recurly
const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined
const client = new recurly.Client(recurlyApiKey)
async function getAccountForUserId(userId) {
try {
return await client.getAccount(`code-${userId}`)
} catch (err) {
if (err instanceof recurly.errors.NotFoundError) {
// An expected error, we don't need to handle it, just return nothing
logger.debug({ userId }, 'no recurly account found for user')
} else {
throw err
}
}
}
async function createAccountForUserId(userId) {
const user = await UserGetter.promises.getUser(userId, {
_id: 1,
first_name: 1,
last_name: 1,
email: 1,
})
const accountCreate = {
code: user._id.toString(),
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
}
const account = await client.createAccount(accountCreate)
logger.debug({ userId, account }, 'created recurly account')
return account
}
/**
* Get a subscription from Recurly
*
* @param {string} subscriptionId
* @return {Promise<RecurlySubscription>}
*/
async function getSubscription(subscriptionId) {
const subscription = await client.getSubscription(`uuid-${subscriptionId}`)
return subscriptionFromApi(subscription)
}
/**
* Get the subscription for a given user
*
* Returns null if the user doesn't have an account or a subscription. Throws an
* error if the user has more than one subscription.
*
* @param {string} userId
* @return {Promise<RecurlySubscription | null>}
*/
async function getSubscriptionForUser(userId) {
try {
const subscriptions = client.listAccountSubscriptions(`code-${userId}`, {
params: { state: 'active', limit: 2 },
})
let result = null
// The async iterator returns a NotFoundError if the account doesn't exist.
for await (const subscription of subscriptions.each()) {
if (result != null) {
throw new OError('User has more than one Recurly subscription', {
userId,
})
}
result = subscription
}
if (result == null) {
return null
}
return subscriptionFromApi(result)
} catch (err) {
if (err instanceof recurly.errors.NotFoundError) {
return null
} else {
throw err
}
}
}
/**
* Request a susbcription change from Recurly
*
* @param {RecurlySubscriptionChangeRequest} changeRequest
*/
async function applySubscriptionChangeRequest(changeRequest) {
const body = subscriptionChangeRequestToApi(changeRequest)
const change = await client.createSubscriptionChange(
`uuid-${changeRequest.subscription.id}`,
body
)
logger.debug(
{ subscriptionId: changeRequest.subscription.id, changeId: change.id },
'created subscription change'
)
}
/**
* Preview a subscription change
*
* @param {RecurlySubscriptionChangeRequest} changeRequest
* @return {Promise<RecurlySubscriptionChange>}
*/
async function previewSubscriptionChange(changeRequest) {
const body = subscriptionChangeRequestToApi(changeRequest)
const subscriptionChange = await client.previewSubscriptionChange(
`uuid-${changeRequest.subscription.id}`,
body
)
return subscriptionChangeFromApi(
changeRequest.subscription,
subscriptionChange
)
}
async function removeSubscriptionChange(subscriptionId) {
const removed = await client.removeSubscriptionChange(subscriptionId)
logger.debug({ subscriptionId }, 'removed pending subscription change')
return removed
}
async function removeSubscriptionChangeByUuid(subscriptionUuid) {
return await removeSubscriptionChange('uuid-' + subscriptionUuid)
}
async function reactivateSubscriptionByUuid(subscriptionUuid) {
return await client.reactivateSubscription('uuid-' + subscriptionUuid)
}
async function cancelSubscriptionByUuid(subscriptionUuid) {
try {
return await client.cancelSubscription('uuid-' + subscriptionUuid)
} catch (err) {
if (err instanceof recurly.errors.ValidationError) {
if (
err.message === 'Only active and future subscriptions can be canceled.'
) {
logger.debug(
{ subscriptionUuid },
'subscription cancellation failed, subscription not active'
)
}
} else {
throw err
}
}
}
async function pauseSubscriptionByUuid(subscriptionUuid, pauseCycles) {
return await client.pauseSubscription('uuid-' + subscriptionUuid, {
remainingPauseCycles: pauseCycles,
})
}
async function resumeSubscriptionByUuid(subscriptionUuid) {
return await client.resumeSubscription('uuid-' + subscriptionUuid)
}
/**
* Get the payment method for the given user
*
* @param {string} userId
* @return {Promise<PaymentMethod>}
*/
async function getPaymentMethod(userId) {
let billingInfo
try {
billingInfo = await client.getBillingInfo(`code-${userId}`)
} catch (error) {
if (error instanceof recurly.errors.NotFoundError) {
throw new MissingBillingInfoError('This account has no billing info', {
userId,
})
}
throw error
}
return paymentMethodFromApi(billingInfo)
}
/**
* Get the configuration for a given add-on
*
* @param {string} planCode
* @param {string} addOnCode
* @return {Promise<RecurlyAddOn>}
*/
async function getAddOn(planCode, addOnCode) {
const addOn = await client.getPlanAddOn(
`code-${planCode}`,
`code-${addOnCode}`
)
return addOnFromApi(addOn)
}
/**
* Get the configuration for a given plan
*
* @param {string} planCode
* @return {Promise<RecurlyPlan>}
*/
async function getPlan(planCode) {
const plan = await client.getPlan(`code-${planCode}`)
return planFromApi(plan)
}
function subscriptionIsCanceledOrExpired(subscription) {
const state = subscription?.recurlyStatus?.state
return state === 'canceled' || state === 'expired'
}
/**
* Build a RecurlySubscription from Recurly API data
*
* @param {recurly.Subscription} apiSubscription
* @return {RecurlySubscription}
*/
function subscriptionFromApi(apiSubscription) {
if (
apiSubscription.uuid == null ||
apiSubscription.plan == null ||
apiSubscription.plan.code == null ||
apiSubscription.plan.name == null ||
apiSubscription.account == null ||
apiSubscription.account.code == null ||
apiSubscription.unitAmount == null ||
apiSubscription.subtotal == null ||
apiSubscription.total == null ||
apiSubscription.currency == null ||
apiSubscription.currentPeriodStartedAt == null ||
apiSubscription.currentPeriodEndsAt == null ||
apiSubscription.createdAt == null
) {
throw new OError('Invalid Recurly subscription', {
subscription: apiSubscription,
})
}
const subscription = new RecurlySubscription({
id: apiSubscription.uuid,
userId: apiSubscription.account.code,
planCode: apiSubscription.plan.code,
planName: apiSubscription.plan.name,
planPrice: apiSubscription.unitAmount,
addOns: (apiSubscription.addOns ?? []).map(subscriptionAddOnFromApi),
subtotal: apiSubscription.subtotal,
taxRate: apiSubscription.taxInfo?.rate ?? 0,
taxAmount: apiSubscription.tax ?? 0,
total: apiSubscription.total,
currency: apiSubscription.currency,
periodStart: apiSubscription.currentPeriodStartedAt,
periodEnd: apiSubscription.currentPeriodEndsAt,
createdAt: apiSubscription.createdAt,
})
if (apiSubscription.pendingChange != null) {
subscription.pendingChange = subscriptionChangeFromApi(
subscription,
apiSubscription.pendingChange
)
}
return subscription
}
/**
* Build a RecurlySubscriptionAddOn from Recurly API data
*
* @param {recurly.SubscriptionAddOn} addOn
* @return {RecurlySubscriptionAddOn}
*/
function subscriptionAddOnFromApi(addOn) {
if (
addOn.addOn == null ||
addOn.addOn.code == null ||
addOn.addOn.name == null ||
addOn.unitAmount == null
) {
throw new OError('Invalid Recurly add-on', { addOn })
}
return new RecurlySubscriptionAddOn({
code: addOn.addOn.code,
name: addOn.addOn.name,
quantity: addOn.quantity ?? 1,
unitPrice: addOn.unitAmount,
})
}
/**
* Build a RecurlySubscriptionChange from Recurly API data
*
* @param {RecurlySubscription} subscription - the current subscription
* @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API
* @return {RecurlySubscriptionChange}
*/
function subscriptionChangeFromApi(subscription, subscriptionChange) {
if (
subscriptionChange.plan == null ||
subscriptionChange.plan.code == null ||
subscriptionChange.plan.name == null ||
subscriptionChange.unitAmount == null
) {
throw new OError('Invalid Recurly subscription change', {
subscriptionChange,
})
}
const nextAddOns = (subscriptionChange.addOns ?? []).map(
subscriptionAddOnFromApi
)
return new RecurlySubscriptionChange({
subscription,
nextPlanCode: subscriptionChange.plan.code,
nextPlanName: subscriptionChange.plan.name,
nextPlanPrice: subscriptionChange.unitAmount,
nextAddOns,
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,
})
}
/**
* Returns a payment method from Recurly API data
*
* @param {recurly.BillingInfo} billingInfo
* @return {PaymentMethod}
*/
function paymentMethodFromApi(billingInfo) {
if (billingInfo.paymentMethod == null) {
throw new OError('Invalid Recurly billing info', { billingInfo })
}
const paymentMethod = billingInfo.paymentMethod
if (paymentMethod.billingAgreementId != null) {
return new PaypalPaymentMethod()
}
if (paymentMethod.cardType == null || paymentMethod.lastFour == null) {
throw new OError('Invalid Recurly billing info', { billingInfo })
}
return new CreditCardPaymentMethod({
cardType: paymentMethod.cardType,
lastFour: paymentMethod.lastFour,
})
}
/**
* Build a RecurlyAddOn from Recurly API data
*
* @param {recurly.AddOn} addOn
* @return {RecurlyAddOn}
*/
function addOnFromApi(addOn) {
if (addOn.code == null || addOn.name == null) {
throw new OError('Invalid Recurly add-on', { addOn })
}
return new RecurlyAddOn({
code: addOn.code,
name: addOn.name,
})
}
/**
* Build a RecurlyPlan from Recurly API data
*
* @param {recurly.Plan} plan
* @return {RecurlyPlan}
*/
function planFromApi(plan) {
if (plan.code == null || plan.name == null) {
throw new OError('Invalid Recurly add-on', { plan })
}
return new RecurlyPlan({
code: plan.code,
name: plan.name,
})
}
/**
* Build an API request from a RecurlySubscriptionChangeRequest
*
* @param {RecurlySubscriptionChangeRequest} changeRequest
* @return {recurly.SubscriptionChangeCreate}
*/
function subscriptionChangeRequestToApi(changeRequest) {
/** @type {recurly.SubscriptionChangeCreate} */
const requestBody = {
timeframe: changeRequest.timeframe,
}
if (changeRequest.planCode != null) {
requestBody.planCode = changeRequest.planCode
}
if (changeRequest.addOnUpdates != null) {
requestBody.addOns = changeRequest.addOnUpdates.map(addOnUpdate => {
/** @type {recurly.SubscriptionAddOnUpdate} */
const update = { code: addOnUpdate.code }
if (addOnUpdate.quantity != null) {
update.quantity = addOnUpdate.quantity
}
if (addOnUpdate.unitPrice != null) {
update.unitAmount = addOnUpdate.unitPrice
}
return update
})
}
return requestBody
}
module.exports = {
errors: recurly.errors,
getAccountForUserId: callbackify(getAccountForUserId),
createAccountForUserId: callbackify(createAccountForUserId),
getSubscription: callbackify(getSubscription),
getSubscriptionForUser: callbackify(getSubscriptionForUser),
previewSubscriptionChange: callbackify(previewSubscriptionChange),
applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
removeSubscriptionChange: callbackify(removeSubscriptionChange),
removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
reactivateSubscriptionByUuid: callbackify(reactivateSubscriptionByUuid),
cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
getPaymentMethod: callbackify(getPaymentMethod),
getAddOn: callbackify(getAddOn),
getPlan: callbackify(getPlan),
subscriptionIsCanceledOrExpired,
pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid),
resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
promises: {
getSubscription,
getSubscriptionForUser,
getAccountForUserId,
createAccountForUserId,
previewSubscriptionChange,
applySubscriptionChangeRequest,
removeSubscriptionChange,
removeSubscriptionChangeByUuid,
reactivateSubscriptionByUuid,
cancelSubscriptionByUuid,
pauseSubscriptionByUuid,
resumeSubscriptionByUuid,
getPaymentMethod,
getAddOn,
getPlan,
},
}