mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-31 04:41:32 +02:00
[web] FL handle subscriptions with missing billing info GitOrigin-RevId: 34209299c039992a80da5739e086beb5d0ede7b0
508 lines
14 KiB
JavaScript
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,
|
|
},
|
|
}
|