first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
source 'https://rubygems.org'
gem 'recurly'
gem 'json'

View File

@@ -0,0 +1,230 @@
import fs from 'node:fs'
import { setTimeout } from 'node:timers/promises'
import * as csv from 'csv'
import minimist from 'minimist'
import recurly from 'recurly'
import Settings from '@overleaf/settings'
const recurlyClient = new recurly.Client(Settings.apis.recurly.apiKey)
// 2400 ms corresponds to approx. 3000 API calls per hour
const DEFAULT_THROTTLE = 2400
async function main() {
const opts = parseArgs()
const inputStream = opts.inputFile
? fs.createReadStream(opts.inputFile)
: process.stdin
const csvReader = getCsvReader(inputStream)
const csvWriter = getCsvWriter(process.stdout)
let lastLoopTimestamp = 0
for await (const change of csvReader) {
const timeSinceLastLoop = Date.now() - lastLoopTimestamp
if (timeSinceLastLoop < opts.throttle) {
await setTimeout(opts.throttle - timeSinceLastLoop)
}
lastLoopTimestamp = Date.now()
try {
await processChange(change, opts)
csvWriter.write({
subscription_uuid: change.subscription_uuid,
status: 'changed',
})
} catch (err) {
if (err instanceof ReportError) {
csvWriter.write({
subscription_uuid: change.subscription_uuid,
status: err.status,
note: err.message,
})
} else {
throw err
}
}
}
process.exit(0)
}
function getCsvReader(inputStream) {
const parser = csv.parse({
columns: true,
cast: (value, context) => {
if (context.header) {
return value
}
switch (context.column) {
case 'unit_amount':
case 'new_unit_amount':
return parseFloat(value)
case 'subscription_add_on_unit_amount_in_cents':
case 'new_subscription_add_on_unit_amount_in_cents':
return value === '' ? null : parseInt(value, 10)
default:
return value
}
},
})
inputStream.pipe(parser)
return parser
}
function getCsvWriter(outputStream) {
const writer = csv.stringify({
columns: ['subscription_uuid', 'status', 'note'],
header: true,
})
writer.on('error', err => {
console.error(err)
process.exit(1)
})
writer.pipe(outputStream)
return writer
}
async function processChange(change, opts) {
const subscription = await fetchSubscription(change.subscription_uuid)
validateChange(change, subscription, opts)
await createSubscriptionChange(change, subscription)
}
async function fetchSubscription(uuid) {
try {
const subscription = await recurlyClient.getSubscription(`uuid-${uuid}`)
return subscription
} catch (err) {
if (err instanceof recurly.errors.NotFoundError) {
throw new ReportError('not-found', 'subscription not found')
} else {
throw err
}
}
}
function validateChange(change, subscription, opts) {
if (subscription.state !== 'active') {
throw new ReportError(
'inactive',
`subscription state: ${subscription.state}`
)
}
if (subscription.plan.code !== change.plan_code) {
throw new ReportError(
'mismatch',
`subscription plan (${subscription.plan.code}) does not match expected plan (${change.plan_code})`
)
}
if (subscription.currency !== change.currency) {
throw new ReportError(
'mismatch',
`subscription currency (${subscription.currency}) does not match expected currency (${change.currency})`
)
}
if (subscription.unitAmount !== change.unit_amount) {
throw new ReportError(
'mismatch',
`subscription price (${subscription.unitAmount}) does not match expected price (${change.unit_amount})`
)
}
if (subscription.pendingChange != null && !opts.force) {
throw new ReportError(
'pending-change',
'subscription already has a pending change'
)
}
if (subscription.addOns.length === 0) {
if (change.subscription_add_on_unit_amount_in_cents != null) {
throw new ReportError('mismatch', 'add-on not found')
}
} else if (subscription.addOns.length === 1) {
const addOn = subscription.addOns[0]
if (addOn.addOn.code !== 'additional-license') {
throw new ReportError(
'mismatch',
`unexpected add-on code: ${addOn.addOn.code}`
)
}
if (
addOn.unitAmount !==
change.subscription_add_on_unit_amount_in_cents / 100
) {
throw new ReportError(
'mismatch',
`add-on price (${addOn.unitAmount}) does not match expected price (${
change.subscription_add_on_unit_amount_in_cents / 100
})`
)
}
} else {
throw new ReportError('mismatch', 'subscription has more than one addon')
}
}
async function createSubscriptionChange(change, subscription) {
const subscriptionChange = {
timeframe: 'renewal',
unitAmount: change.new_unit_amount,
}
const addOn = subscription.addOns[0]
if (addOn != null) {
subscriptionChange.addOns = [
{
id: addOn.id,
unitAmount: change.new_subscription_add_on_unit_amount_in_cents / 100,
},
]
}
await recurlyClient.createSubscriptionChange(
`uuid-${change.subscription_uuid}`,
subscriptionChange
)
}
function parseArgs() {
const argv = minimist(process.argv.slice(2), {
string: ['throttle'],
boolean: ['help', 'force'],
})
if (argv.help || argv._.length > 1) {
usage()
process.exit(1)
}
const opts = {
inputFile: argv._[0],
force: argv.force,
throttle: argv.throttle ? parseInt(argv.throttle, 10) : DEFAULT_THROTTLE,
}
return opts
}
function usage() {
console.error(`Usage: node scripts/recurly/change_prices_at_renewal.mjs [OPTS] [INPUT-FILE]
Options:
--throttle DURATION Minimum time (in ms) between subscriptions processed
--force Overwrite any existing pending changes
`)
}
class ReportError extends Error {
constructor(status, message) {
super(message)
this.status = status
}
}
try {
await main()
} catch (error) {
console.error(error)
process.exit(1)
}

View File

@@ -0,0 +1,131 @@
import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.js'
import minimist from 'minimist'
import logger from '@overleaf/logger'
import { fileURLToPath } from 'node:url'
const waitMs =
fileURLToPath(import.meta.url) === process.argv[1]
? timeout => new Promise(resolve => setTimeout(() => resolve(), timeout))
: () => Promise.resolve()
// NOTE: Errors are not propagated to the caller
const handleAPIError = async (source, id, error) => {
logger.warn(`Errors in ${source} with id=${id}`, error)
if (typeof error === 'string' && error.match(/429$/)) {
return waitMs(1000 * 60 * 5)
}
await waitMs(80)
}
/**
* @returns {Promise<{
* INVOICES_COLLECTED: string[],
* INVOICES_COLLECTED_SUCCESS: string[],
* USERS_COLLECTED: string[],
* }>}
*/
const main = async () => {
const attemptInvoiceCollection = async invoice => {
const isPaypal = await isAccountUsingPaypal(invoice)
if (!isPaypal) {
return
}
const accountId = invoice.account.url.match(/accounts\/(.*)/)[1]
if (USERS_COLLECTED.indexOf(accountId) > -1) {
logger.warn(`Skipping duplicate user ${accountId}`)
return
}
INVOICES_COLLECTED.push(invoice.invoice_number)
USERS_COLLECTED.push(accountId)
if (DRY_RUN) {
return
}
try {
await RecurlyWrapper.promises.attemptInvoiceCollection(
invoice.invoice_number
)
INVOICES_COLLECTED_SUCCESS.push(invoice.invoice_number)
await waitMs(80)
} catch (error) {
return handleAPIError(
'attemptInvoiceCollection',
invoice.invoice_number,
error
)
}
}
const isAccountUsingPaypal = async invoice => {
const accountId = invoice.account.url.match(/accounts\/(.*)/)[1]
try {
const response = await RecurlyWrapper.promises.getBillingInfo(accountId)
await waitMs(80)
return !!response.billing_info.paypal_billing_agreement_id
} catch (error) {
return handleAPIError('billing info', accountId, error)
}
}
const attemptInvoicesCollection = async () => {
let getPage = await RecurlyWrapper.promises.getPaginatedEndpointIterator(
'invoices',
{ state: 'past_due' }
)
while (getPage) {
const { items, getNextPage } = await getPage()
logger.info('invoices', items?.length)
for (const invoice of items) {
await attemptInvoiceCollection(invoice)
}
getPage = getNextPage
}
}
const argv = minimist(process.argv.slice(2))
const DRY_RUN = argv.n !== undefined
const INVOICES_COLLECTED = []
const INVOICES_COLLECTED_SUCCESS = []
const USERS_COLLECTED = []
try {
await attemptInvoicesCollection()
const diff = INVOICES_COLLECTED.length - INVOICES_COLLECTED_SUCCESS.length
if (diff !== 0) {
logger.warn(`Invoices collection failed for ${diff} invoices`)
}
return {
INVOICES_COLLECTED,
INVOICES_COLLECTED_SUCCESS,
USERS_COLLECTED,
}
} finally {
logger.info(
`DONE (DRY_RUN=${DRY_RUN}). ${INVOICES_COLLECTED.length} invoices collection attempts for ${USERS_COLLECTED.length} users. ${INVOICES_COLLECTED_SUCCESS.length} successful collections`
)
console.dir(
{
INVOICES_COLLECTED,
INVOICES_COLLECTED_SUCCESS,
USERS_COLLECTED,
},
{ maxArrayLength: null }
)
}
}
if (fileURLToPath(import.meta.url) === process.argv[1]) {
try {
await main()
logger.info('Done.')
process.exit(0)
} catch (error) {
logger.error({ error }, 'Error')
process.exit(1)
}
}
export default { main }

View File

@@ -0,0 +1,55 @@
// @ts-check
import settings from '@overleaf/settings'
import recurly from 'recurly'
const ADD_ON_CODE = process.argv[2]
async function main() {
if (!ADD_ON_CODE) {
console.error('Missing add-on code')
console.error(
'Usage: node scripts/recurly/generate_addon_prices.mjs ADD_ON_CODE'
)
process.exit(1)
}
const localizedAddOnsPricing = {}
const monthlyPlan = await getPlan(ADD_ON_CODE)
if (monthlyPlan == null) {
console.error(`Monthly plan missing in Recurly: ${ADD_ON_CODE}`)
process.exit(1)
}
for (const { currency, unitAmount } of monthlyPlan.currencies ?? []) {
if (!localizedAddOnsPricing[currency]) {
localizedAddOnsPricing[currency] = { [ADD_ON_CODE]: {} }
}
localizedAddOnsPricing[currency][ADD_ON_CODE].monthly = unitAmount
}
const annualPlan = await getPlan(`${ADD_ON_CODE}-annual`)
if (annualPlan == null) {
console.error(`Annual plan missing in Recurly: ${ADD_ON_CODE}-annual`)
process.exit(1)
}
for (const { currency, unitAmount } of annualPlan.currencies ?? []) {
if (!localizedAddOnsPricing[currency]) {
localizedAddOnsPricing[currency] = { [ADD_ON_CODE]: {} }
}
localizedAddOnsPricing[currency][ADD_ON_CODE].annual = unitAmount
}
console.log(JSON.stringify({ localizedAddOnsPricing }, null, 2))
}
/**
* Get a plan configuration from Recurly
*
* @param {string} planCode
*/
async function getPlan(planCode) {
const recurlyClient = new recurly.Client(settings.apis.recurly.apiKey)
return await recurlyClient.getPlan(`code-${planCode}`)
}
await main()

View File

@@ -0,0 +1,121 @@
// script to generate plan prices for recurly from a csv file
//
// Usage:
//
// $ node scripts/recurly/generate_recurly_prices.mjs -f input.csv -o prices.json
//
// The input csv file has the following format:
//
// plan_code,USD,EUR,GBP,...
// student,9,8,7,...
// student-annual,89,79,69,...
// group_professional_2_educational,558,516,446,...
//
// The output file format is the JSON of the plans returned by recurly, with an
// extra _addOns property for the addOns associated with that plan.
//
// The output can be used as input for the upload script `recurly_prices.js`.
import minimist from 'minimist'
// https://github.com/import-js/eslint-plugin-import/issues/1810
// eslint-disable-next-line import/no-unresolved
import * as csv from 'csv/sync'
import _ from 'lodash'
import fs from 'node:fs'
const argv = minimist(process.argv.slice(2), {
string: ['output', 'file'],
alias: { o: 'output', f: 'file' },
default: { output: '/dev/stdout' },
})
// All currency codes are 3 uppercase letters
const CURRENCY_CODE_REGEX = /^[A-Z]{3}$/
// Group plans have a plan code of the form group_name_size_type, e.g.
const GROUP_SIZE_REGEX = /group_\w+_([0-9]+)_\w+/
// Only group plans with more than 4 users can have additional licenses
const SINGLE_LICENSE_MAX_GROUP_SIZE = 4
// Compute prices for the base plan
function computePrices(plan) {
const prices = _.pickBy(plan, (value, key) => CURRENCY_CODE_REGEX.test(key))
const result = []
for (const currency in prices) {
result.push({
currency,
setupFee: 0,
unitAmount: parseInt(prices[currency], 10),
})
}
return _.sortBy(result, 'currency')
}
// Handle prices for license add-ons associated with group plans
function isGroupPlan(plan) {
return plan.plan_code.startsWith('group_')
}
function getGroupSize(plan) {
// extract the group size from the plan code group_name_size_type using a regex
const match = plan.plan_code.match(GROUP_SIZE_REGEX)
if (!match) {
throw new Error(`cannot find group size in plan code: ${plan.plan_code}`)
}
const size = parseInt(match[1], 10)
return size
}
function computeAddOnPrices(prices, size) {
// The price of an additional license is the per-user cost of the base plan,
// i.e. the price of the plan divided by the group size of the plan
return prices.map(price => {
return {
currency: price.currency,
unitAmount: Math.round((100 * price.unitAmount) / size) / 100,
unitAmountDecimal: null,
}
})
}
// Convert the raw records into the output format
function transformRecordToPlan(record) {
const prices = computePrices(record)
// The base plan has no add-ons
const plan = {
code: record.plan_code,
currencies: prices,
}
// Large group plans have an add-on for additional licenses
if (isGroupPlan(record)) {
const size = getGroupSize(record)
if (size > SINGLE_LICENSE_MAX_GROUP_SIZE) {
const addOnPrices = computeAddOnPrices(prices, size)
plan._addOns = [
{
code: 'additional-license',
currencies: addOnPrices,
},
]
}
}
return plan
}
function generate(inputFile, outputFile) {
const input = fs.readFileSync(inputFile, 'utf8')
const rawRecords = csv.parse(input, { columns: true })
// transform the raw records into the output format
const plans = _.sortBy(rawRecords, 'plan_code').map(transformRecordToPlan)
const output = JSON.stringify(plans, null, 2)
fs.writeFileSync(outputFile, output)
}
if (argv.file) {
generate(argv.file, argv.output)
} else {
console.log('usage:\n' + ' --file input.csv -o file.json\n')
}

View File

@@ -0,0 +1,103 @@
import Settings from '@overleaf/settings'
import recurly from 'recurly'
import fs from 'node:fs'
import { setTimeout } from 'node:timers/promises'
import minimist from 'minimist'
import * as csv from 'csv'
import Stream from 'node:stream/promises'
const recurlyApiKey = Settings.apis.recurly.apiKey
if (!recurlyApiKey) {
throw new Error('Recurly API key is not set in the settings')
}
const client = new recurly.Client(recurlyApiKey)
function usage() {
console.error(
'Script to retrieve details of manually billed users from Recurly'
)
console.error('')
console.error('Usage:')
console.error(
' node scripts/recurly/get_manually_billed_users_details.mjs [options]'
)
console.error('')
console.error('Options:')
console.error(
' --input, -i <file> Path to CSV file containing subscription_id, period_end, currency (can be exported from Recurly)'
)
console.error(' --output, -o <file> Path to output CSV file')
console.error('')
console.error('Input format:')
console.error(
' CSV file with the following columns: subscription_id, period_end, currency (header row is skipped)'
)
}
function parseArgs() {
return minimist(process.argv.slice(2), {
alias: { i: 'input', o: 'output' },
string: ['input', 'output'],
})
}
async function enrichRow(row) {
const account = await client.getAccount(`code-${row.account_code}`)
return {
...row,
email: account.email,
first_name: account.firstName,
last_name: account.lastName,
cc_emails: account.ccEmails,
}
}
async function main() {
const { input: inputPath, output: outputPath, h, help } = parseArgs()
if (help || h || !inputPath || !outputPath) {
usage()
process.exit(0)
}
let processedCount = 0
await Stream.pipeline([
fs.createReadStream(inputPath),
csv.parse({ columns: true }),
async function* (rows) {
for await (const row of rows) {
try {
yield await enrichRow(row)
} catch (error) {
console.error(`Error processing subscription ${row.subscription_id}`)
}
processedCount++
if (processedCount % 1 === 0) {
console.log(`Processed ${processedCount} subscriptions`)
}
await setTimeout(1000)
}
},
csv.stringify({
header: true,
columns: {
subscription_id: 'subscription_id',
current_period_ends_at: 'period_end',
currency: 'currency',
email: 'email',
first_name: 'first_name',
last_name: 'last_name',
cc_emails: 'cc_emails',
},
}),
fs.createWriteStream(outputPath),
])
console.log(`Processed ${processedCount} subscriptions in total`)
}
try {
await main()
process.exit(0)
} catch (error) {
console.error(error)
process.exit(1)
}

View File

@@ -0,0 +1,107 @@
import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.js'
import async from 'async'
import { Parser as CSVParser } from 'json2csv'
const NOW = new Date()
const slowCallback = (callback, error, data) =>
setTimeout(() => callback(error, data), 80)
const handleAPIError = (type, account, error, callback) => {
console.warn(
`Errors getting ${type} for account ${account.account_code}`,
error
)
if (typeof error === 'string' && error.match(/429$/)) {
return setTimeout(callback, 1000 * 60 * 5)
}
slowCallback(callback)
}
const getAccountSubscription = (account, callback) =>
RecurlyWrapper.getSubscriptions(account.account_code, (error, response) => {
if (error) {
return handleAPIError('subscriptions', account, error, callback)
}
slowCallback(callback, null, response.subscriptions[0])
})
const isAccountUsingPaypal = (account, callback) =>
RecurlyWrapper.getBillingInfo(account.account_code, (error, response) => {
if (error) {
return handleAPIError('billing info', account, error, callback)
}
if (response.billing_info.paypal_billing_agreement_id) {
return slowCallback(callback, null, true)
}
slowCallback(callback, null, false)
})
const printAccountCSV = (account, callback) => {
isAccountUsingPaypal(account, (error, isPaypal) => {
if (error || !isPaypal) {
return callback(error)
}
getAccountSubscription(account, (error, subscription) => {
if (error || !subscription) {
return callback(error)
}
const endAt = new Date(subscription.current_period_ends_at)
if (subscription.expires_at) {
return callback()
}
const csvData = {
email: account.email,
first_name: account.first_name,
last_name: account.last_name,
hosted_login_token: account.hosted_login_token,
billing_info_url: `https://sharelatex.recurly.com/account/billing_info/edit?ht=${account.hosted_login_token}`,
account_management_url: `https://sharelatex.recurly.com/account/${account.hosted_login_token}`,
current_period_ends_at: `${endAt.getFullYear()}-${
endAt.getMonth() + 1
}-${endAt.getDate()}`,
current_period_ends_at_segment: parseInt(
((endAt - NOW) / 1000 / 3600 / 24 / 365) * 7
),
}
callback(null, csvData)
})
})
}
const printAccountsCSV = callback => {
RecurlyWrapper.getPaginatedEndpoint(
'accounts',
{ state: 'subscriber' },
(error, accounts) => {
if (error) {
return callback(error)
}
async.mapSeries(accounts, printAccountCSV, (error, csvData) => {
csvData = csvData.filter(d => !!d)
callback(error, csvData)
})
}
)
}
const csvFields = [
'email',
'first_name',
'last_name',
'hosted_login_token',
'billing_info_url',
'account_management_url',
'current_period_ends_at',
'current_period_ends_at_segment',
]
const csvParser = new CSVParser({ csvFields })
// print each account
printAccountsCSV((error, csvData) => {
if (error) {
throw error
}
console.log(csvParser.parse(csvData))
process.exit()
})

View File

@@ -0,0 +1,47 @@
// Get prices from Recurly in GroupPlansData format, ie to update:
// app/templates/plans/groups.json
//
// Usage example:
// node scripts/recurly/get_recurly_group_prices.mjs
import recurly from 'recurly'
import Settings from '@overleaf/settings'
const recurlySettings = Settings.apis.recurly
const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined
const client = new recurly.Client(recurlyApiKey)
async function getRecurlyGroupPrices() {
const prices = {}
const plans = client.listPlans({ params: { limit: 200 } })
for await (const plan of plans.each()) {
if (plan.code.substr(0, 6) === 'group_') {
const [, type, size, usage] = plan.code.split('_')
plan.currencies.forEach(planPricing => {
const { currency, unitAmount } = planPricing
prices[usage] = prices[usage] || {}
prices[usage][type] = prices[usage][type] || {}
prices[usage][type][currency] = prices[usage][type][currency] || {}
prices[usage][type][currency][size] = {
price_in_cents: unitAmount * 100,
}
})
}
}
return prices
}
async function main() {
const prices = await getRecurlyGroupPrices()
console.log(JSON.stringify(prices, undefined, 2))
}
try {
await main()
process.exit(0)
} catch (error) {
console.error({ error })
process.exit(1)
}

View File

@@ -0,0 +1,226 @@
// script to sync plan prices to/from recurly
//
// Usage:
//
// Save current plan and addon prices to file
// $ node scripts/recurly/recurly_prices.mjs --download -o prices.json
//
// Upload new plan and addon prices (change --dry-run to --commit to make the change)
// $ node scripts/recurly/recurly_prices.mjs --upload -f prices.json --dry-run
//
// File format is JSON of the plans returned by recurly, with an extra _addOns property for the
// addOns associated with that plan.
//
// The idea is to download the current prices to a file, update them locally (e.g. via a script)
// and then upload them to recurly.
import recurly from 'recurly'
import Settings from '@overleaf/settings'
import minimist from 'minimist'
import _ from 'lodash'
import fs from 'node:fs'
const recurlySettings = Settings.apis.recurly
const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined
const client = new recurly.Client(recurlyApiKey)
async function getRecurlyPlans() {
const plans = client.listPlans({ params: { limit: 200, state: 'active' } })
const result = []
for await (const plan of plans.each()) {
plan._addOns = await getRecurlyPlanAddOns(plan) // store the addOns in a private property
if (VERBOSE) {
console.error('plan', plan.code, 'found', plan._addOns.length, 'addons')
}
result.push(plan)
}
return _.sortBy(result, 'code')
}
async function getRecurlyPlanAddOns(plan) {
// also store the addons for each plan
const addOns = await client.listPlanAddOns(plan.id, {
params: { limit: 200, state: 'active' },
})
const result = []
for await (const addOn of addOns.each()) {
if (addOn.code === 'additional-license') {
result.push(addOn)
} else {
console.error('UNRECOGNISED ADD-ON CODE', plan.code, addOn.code)
}
}
return result
}
async function download(outputFile) {
const plans = await getRecurlyPlans()
console.error('retrieved', plans.length, 'plans')
fs.writeFileSync(outputFile, JSON.stringify(plans, null, 2))
}
async function upload(inputFile) {
const localPlans = JSON.parse(fs.readFileSync(inputFile))
console.error('local plans', localPlans.length)
console.error('checking remote plans for consistency')
const remotePlans = await getRecurlyPlans() // includes addOns
// compare local with remote
console.error('remote plans', remotePlans.length)
const matching = _.intersectionBy(localPlans, remotePlans, 'code')
const localOnly = _.differenceBy(localPlans, remotePlans, 'code')
const remoteOnly = _.differenceBy(remotePlans, localPlans, 'code')
console.error(
'plan status:',
matching.length,
'matching,',
localOnly.length,
'local only,',
remoteOnly.length,
'remote only.'
)
if (localOnly.length > 0) {
const localOnlyPlanCodes = localOnly.map(p => p.code)
throw new Error(
`plans not found in Recurly: ${localOnlyPlanCodes.join(', ')}`
)
}
// update remote plan pricing with local version
for (const localPlan of localPlans) {
console.error(`=== ${localPlan.code} ===`)
await updatePlan(localPlan)
if (!localPlan._addOns?.length) {
console.error('no addons for this plan')
continue
}
for (const localPlanAddOn of localPlan._addOns) {
await updatePlanAddOn(localPlan, localPlanAddOn)
}
process.stderr.write('\n')
}
}
async function updatePlan(localPlan) {
const planCodeId = `code-${localPlan.code}`
const originalPlan = await client.getPlan(planCodeId)
const changes = _.differenceWith(
localPlan.currencies,
originalPlan.currencies,
(a, b) => _.isEqual(a, _.assign({}, b))
)
if (changes.length === 0) {
console.error('no changes to plan currencies')
return
} else {
console.error('changes', changes)
}
const planUpdate = { currencies: localPlan.currencies }
try {
if (DRY_RUN) {
console.error('skipping update to', planCodeId)
return
}
const newPlan = await client.updatePlan(planCodeId, planUpdate)
if (VERBOSE) {
console.error('new plan', newPlan)
}
} catch (err) {
console.error('failed to update', localPlan.code, 'error', err)
}
}
async function updatePlanAddOn(plan, localAddOn) {
if (localAddOn.code != null && localAddOn.code !== 'additional-license') {
return
}
const planCodeId = `code-${plan.code}`
const addOnId = 'code-additional-license'
let originalPlanAddOn
try {
originalPlanAddOn = await client.getPlanAddOn(planCodeId, addOnId)
} catch (error) {
if (error instanceof recurly.errors.NotFoundError) {
console.error('plan add-on not found', planCodeId, addOnId)
return
} else {
throw error
}
}
const changes = _.differenceWith(
localAddOn.currencies,
originalPlanAddOn.currencies,
(a, b) => _.isEqual(a, _.assign({}, b))
)
if (changes.length === 0) {
console.error('no changes to addon currencies')
return
} else {
console.error('changes', changes)
}
const planAddOnUpdate = { currencies: localAddOn.currencies }
try {
if (DRY_RUN) {
console.error('skipping update to additional license for', planCodeId)
return
}
const newPlanAddOn = await client.updatePlanAddOn(
planCodeId,
addOnId,
planAddOnUpdate
)
if (VERBOSE) {
console.error('new plan addon', newPlanAddOn)
}
} catch (err) {
console.error(
'failed to update plan addon',
plan.code,
'=>',
localAddOn.code
)
}
}
const argv = minimist(process.argv.slice(2), {
boolean: ['download', 'upload', 'dry-run', 'commit', 'verbose'],
string: ['output', 'file'],
alias: { o: 'output', f: 'file', v: 'verbose' },
default: { output: '/dev/stdout' },
})
const DRY_RUN = argv['dry-run']
const COMMIT = argv.commit
const VERBOSE = argv.verbose
if (argv.download === argv.upload) {
console.error('specify one of --download or --upload')
process.exit(1)
}
if (argv.upload && DRY_RUN === COMMIT) {
console.error('specify one of --dry-run or --commit when uploading prices')
process.exit(1)
}
if (argv.download) {
try {
await download(argv.output)
process.exit(0)
} catch (error) {
console.error({ error })
process.exit(1)
}
} else if (argv.upload) {
try {
await upload(argv.file)
process.exit(0)
} catch (error) {
console.error({ error })
process.exit(1)
}
} else {
console.log(
'usage:\n' + ' --save -o file.json\n' + ' --load -f file.json\n'
)
}

View File

@@ -0,0 +1,186 @@
import { Subscription } from '../../app/src/models/Subscription.js'
import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.js'
import SubscriptionUpdater from '../../app/src/Features/Subscription/SubscriptionUpdater.js'
import minimist from 'minimist'
import { setTimeout } from 'node:timers/promises'
import util from 'node:util'
import pLimit from 'p-limit'
util.inspect.defaultOptions.maxArrayLength = null
const ScriptLogger = {
checkedSubscriptionsCount: 0,
mismatchSubscriptionsCount: 0,
allMismatchReasons: {},
// make sure all `allMismatchReasons` are displayed in the output
recordMismatch: (subscription, recurlySubscription) => {
const mismatchReasons = {}
if (subscription.planCode !== recurlySubscription.plan.plan_code) {
mismatchReasons.recurlyPlan = recurlySubscription.plan.plan_code
mismatchReasons.olPlan = subscription.planCode
}
if (recurlySubscription.state === 'expired') {
mismatchReasons.state = 'expired'
}
if (!Object.keys(mismatchReasons).length) {
return
}
ScriptLogger.mismatchSubscriptionsCount += 1
const mismatchReasonsString = JSON.stringify(mismatchReasons)
if (ScriptLogger.allMismatchReasons[mismatchReasonsString]) {
ScriptLogger.allMismatchReasons[mismatchReasonsString].push({
id: subscription._id,
name: subscription.planCode,
})
} else {
ScriptLogger.allMismatchReasons[mismatchReasonsString] = [
{
id: subscription._id,
name: subscription.planCode,
},
]
}
},
printProgress: () => {
console.warn(
`Subscriptions checked: ${ScriptLogger.checkedSubscriptionsCount}. Mismatches: ${ScriptLogger.mismatchSubscriptionsCount}`
)
},
printSummary: () => {
console.log('All Mismatch Reasons:', ScriptLogger.allMismatchReasons)
console.log(
'Mismatch Subscriptions Count',
ScriptLogger.mismatchSubscriptionsCount
)
},
}
const handleSyncSubscriptionError = async (subscription, error) => {
console.warn(`Errors with subscription id=${subscription._id}:`, error)
if (typeof error === 'string' && error.match(/429$/)) {
await setTimeout(1000 * 60 * 5)
return
}
if (typeof error === 'string' && error.match(/5\d\d$/)) {
await setTimeout(1000 * 60)
await syncSubscription(subscription)
return
}
await setTimeout(80)
}
const syncSubscription = async subscription => {
let recurlySubscription
try {
recurlySubscription = await RecurlyWrapper.promises.getSubscription(
subscription.recurlySubscription_id
)
} catch (error) {
await handleSyncSubscriptionError(subscription, error)
return
}
ScriptLogger.recordMismatch(subscription, recurlySubscription)
if (!COMMIT) {
return
}
try {
await SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
recurlySubscription,
subscription,
{}
)
} catch (error) {
await handleSyncSubscriptionError(subscription, error)
return
}
await setTimeout(80)
}
const syncSubscriptions = async subscriptions => {
const limit = pLimit(ASYNC_LIMIT)
return await Promise.all(
subscriptions.map(subscription =>
limit(() => syncSubscription(subscription))
)
)
}
const loopForSubscriptions = async skipInitial => {
let skip = skipInitial
// iterate while there are more subscriptions to fetch
while (true) {
const subscriptions = await Subscription.find({
recurlySubscription_id: { $exists: true, $ne: '' },
})
.sort('_id')
.skip(skip)
.limit(FETCH_LIMIT)
.exec()
if (subscriptions.length === 0) {
console.warn('DONE')
return
}
await syncSubscriptions(subscriptions)
ScriptLogger.checkedSubscriptionsCount += subscriptions.length
retryCounter = 0
ScriptLogger.printProgress()
ScriptLogger.printSummary()
skip += FETCH_LIMIT
}
}
let retryCounter = 0
const run = async () => {
while (true) {
try {
await loopForSubscriptions(
MONGO_SKIP + ScriptLogger.checkedSubscriptionsCount
)
break
} catch (error) {
if (retryCounter < 3) {
console.error(error)
retryCounter += 1
console.warn(`RETRYING IN 60 SECONDS. (${retryCounter}/3)`)
await setTimeout(60000)
} else {
console.error('Failed after 3 retries')
throw error
}
}
}
}
let FETCH_LIMIT, ASYNC_LIMIT, COMMIT, MONGO_SKIP
const setup = () => {
const argv = minimist(process.argv.slice(2))
FETCH_LIMIT = argv.fetch ? argv.fetch : 100
ASYNC_LIMIT = argv.async ? argv.async : 10
MONGO_SKIP = argv.skip ? argv.skip : 0
COMMIT = argv.commit !== undefined
if (!COMMIT) {
console.warn('Doing dry run without --commit')
}
if (MONGO_SKIP) {
console.warn(`Skipping first ${MONGO_SKIP} records`)
}
}
setup()
await run()
process.exit()

View File

@@ -0,0 +1,135 @@
// @ts-check
import fs from 'node:fs'
import minimist from 'minimist'
import {
db,
READ_PREFERENCE_SECONDARY,
} from '../../app/src/infrastructure/mongodb.js'
/**
* @import { ObjectId } from 'mongodb-legacy'
*/
const OPTS = parseArgs()
const expectedManualRecurlyIds = readFile(OPTS.filename)
const idsToSetToManual = await getSubscriptionIdsToSetToManual(
expectedManualRecurlyIds
)
const idsToSetToAutomatic = await getSubscriptionIdsToSetToAutomatic(
expectedManualRecurlyIds
)
if (idsToSetToManual.length > 0) {
if (OPTS.commit) {
console.log(
`Setting ${idsToSetToManual.length} subscriptions to manual invoice collection...`
)
await setCollectionMethod(idsToSetToManual, 'manual')
} else {
console.log(
`Would set ${idsToSetToManual.length} subscriptions to manual invoice collection`
)
}
}
if (idsToSetToAutomatic.length > 0) {
if (OPTS.commit) {
console.log(
`Setting ${idsToSetToAutomatic.length} subscriptions to automatic invoice collection...`
)
await setCollectionMethod(idsToSetToAutomatic, 'automatic')
} else {
console.log(
`Would set ${idsToSetToAutomatic.length} subscriptions to automatic invoice collection`
)
}
}
if (!OPTS.commit) {
console.log('This was a dry run. Add the --commit option to apply changes')
}
process.exit(0)
function parseArgs() {
const args = minimist(process.argv.slice(2), {
boolean: ['commit'],
})
if (args._.length !== 1) {
usage()
process.exit(1)
}
return {
filename: args._[0],
commit: args.commit,
}
}
function usage() {
console.log(`Usage: node set_manually_collected_subscriptions.mjs FILE [--commit]
where FILE contains the list of subscription ids that are manually collected`)
}
function readFile(filename) {
const contents = fs.readFileSync(filename, { encoding: 'utf-8' })
const subscriptionIds = contents.split('\n').filter(id => id.length > 0)
return subscriptionIds
}
/**
* Get the ids of subscriptions that need to have their collection method set to
* manual
*
* @param {string[]} expectedManualRecurlyIds
* @return {Promise<ObjectId[]>}
*/
async function getSubscriptionIdsToSetToManual(expectedManualRecurlyIds) {
const ids = await db.subscriptions
.find(
{
recurlySubscription_id: { $in: expectedManualRecurlyIds },
collectionMethod: { $ne: 'manual' },
},
{ projection: { _id: 1 }, readPreference: READ_PREFERENCE_SECONDARY }
)
.map(record => record._id)
.toArray()
return ids
}
/**
* Get the ids of subscriptions that need to have their collection method set to
* automatic
*
* @param {string[]} expectedManualRecurlyIds
* @return {Promise<ObjectId[]>}
*/
async function getSubscriptionIdsToSetToAutomatic(expectedManualRecurlyIds) {
const ids = await db.subscriptions
.find(
{
recurlySubscription_id: { $nin: expectedManualRecurlyIds },
collectionMethod: 'manual',
},
{ projection: { _id: 1 }, readPreference: READ_PREFERENCE_SECONDARY }
)
.map(record => record._id)
.toArray()
return ids
}
/**
* Set the collection method for the given subscriptions
*
* @param {ObjectId[]} subscriptionIds
* @param {"automatic" | "manual"} collectionMethod
*/
async function setCollectionMethod(subscriptionIds, collectionMethod) {
await db.subscriptions.updateMany(
{ _id: { $in: subscriptionIds } },
{ $set: { collectionMethod } }
)
}

View File

@@ -0,0 +1,219 @@
// @ts-check
const _ = require('lodash')
const recurly = require('recurly')
const minimist = require('minimist')
const Settings = require('@overleaf/settings')
const ADD_ON_CODE = 'assistant'
const ADD_ON_NAME = 'Error Assist'
const INDIVIDUAL_PLANS = [
'student',
'collaborator',
'professional',
'paid-personal',
]
const INDIVIDUAL_VARIANTS = ['', '_free_trial_7_days']
const GROUP_PLANS = ['collaborator', 'professional']
const GROUP_SIZES = [2, 3, 4, 5, 10, 20, 50]
const GROUP_SEGMENTS = ['educational', 'enterprise']
const ARGS = parseArgs()
const recurlyClient = new recurly.Client(Settings.apis.recurly.apiKey)
function usage() {
console.log(`Usage: setup_assistant_addon.js [--commit]
This script will copy prices from the ${ADD_ON_CODE} and ${ADD_ON_CODE}-annual
plans into the ${ADD_ON_CODE} add-on for every other plan
Options:
--commit Make actual changes to Recurly
`)
}
function parseArgs() {
const args = minimist(process.argv.slice(2), {
boolean: ['commit', 'help'],
})
if (args.help) {
usage()
process.exit(0)
}
return { commit: args.commit }
}
async function main() {
const monthlyPlan = await getPlan(ADD_ON_CODE)
if (monthlyPlan == null) {
console.error(`Monthly plan missing in Recurly: ${ADD_ON_CODE}`)
process.exit(1)
}
console.log('\nMonthly prices:')
for (const { currency, unitAmount } of monthlyPlan.currencies ?? []) {
console.log(`- ${unitAmount} ${currency}`)
}
const annualPlan = await getPlan(`${ADD_ON_CODE}-annual`)
if (annualPlan == null) {
console.error(`Annual plan missing in Recurly: ${ADD_ON_CODE}-annual`)
process.exit(1)
}
console.log('\nAnnual prices:')
for (const { currency, unitAmount } of annualPlan.currencies ?? []) {
console.log(`- ${unitAmount} ${currency}`)
}
console.log()
for (const { code, annual } of getPlanSpecs()) {
const prices = annual ? annualPlan.currencies : monthlyPlan.currencies
await setupAddOn(code, prices ?? [])
}
if (ARGS.commit) {
console.log('Done')
} else {
console.log('This was a dry run. Re-run with --commit to apply changes.')
}
}
function* getPlanSpecs() {
for (const plan of INDIVIDUAL_PLANS) {
for (const variant of INDIVIDUAL_VARIANTS) {
yield { code: `${plan}${variant}`, annual: false }
yield { code: `${plan}-annual${variant}`, annual: true }
}
}
for (const plan of GROUP_PLANS) {
for (const size of GROUP_SIZES) {
for (const segment of GROUP_SEGMENTS) {
yield { code: `group_${plan}_${size}_${segment}`, annual: true }
}
}
}
}
/**
* Create or update the assistant add-on for a plan
*
* @param {string} planCode
* @param {recurly.AddOnPricing[]} prices
*/
async function setupAddOn(planCode, prices) {
const currentAddOn = await getAddOn(planCode, ADD_ON_CODE)
const newAddOnConfig = getAddOnConfig(prices)
if (currentAddOn == null || currentAddOn.deletedAt != null) {
await createAddOn(planCode, newAddOnConfig)
} else if (_.isMatch(currentAddOn, newAddOnConfig)) {
console.log(`No changes for plan ${planCode}`)
} else {
await updateAddOn(planCode, newAddOnConfig)
}
}
/**
* Get a plan configuration from Recurly
*
* @param {string} planCode
*/
async function getPlan(planCode) {
try {
return await recurlyClient.getPlan(`code-${planCode}`)
} catch (err) {
if (err instanceof recurly.errors.NotFoundError) {
return null
} else {
throw err
}
}
}
/**
* Get an add-on configuration from Recurly
*
* @param {string} planCode
* @param {string} addOnCode
*/
async function getAddOn(planCode, addOnCode) {
try {
return await recurlyClient.getPlanAddOn(
`code-${planCode}`,
`code-${addOnCode}`
)
} catch (err) {
if (err instanceof recurly.errors.NotFoundError) {
return null
} else {
throw err
}
}
}
/**
* Create the add-on described by the given config on the given plan
*
* @param {string} planCode
* @param {recurly.AddOnCreate} config
*/
async function createAddOn(planCode, config) {
if (ARGS.commit) {
console.log(`Creating ${ADD_ON_CODE} add-on for plan ${planCode}...`)
await recurlyClient.createPlanAddOn(`code-${planCode}`, config)
} else {
console.log(`Would create ${ADD_ON_CODE} add-on for plan ${planCode}`)
}
}
/**
* Update the add-on described by the given config on the given plan
*
* @param {string} planCode
* @param {recurly.AddOnUpdate} config
*/
async function updateAddOn(planCode, config) {
if (ARGS.commit) {
console.log(`Updating ${ADD_ON_CODE} add-on for plan ${planCode}...`)
await recurlyClient.updatePlanAddOn(
`code-${planCode}`,
`code-${ADD_ON_CODE}`,
config
)
} else {
console.log(`Would update ${ADD_ON_CODE} add-on for plan ${planCode}`)
}
}
/**
* Get an assistant add-on config
*
* @param {recurly.AddOnPricing[]} prices
*/
function getAddOnConfig(prices) {
return {
code: ADD_ON_CODE,
name: ADD_ON_NAME,
optional: true,
currencies: prices.map(price =>
_.pick(
price,
'currency',
'unitAmount',
'unitAmountDecimal',
'taxInclusive'
)
),
}
}
main()
.then(() => {
process.exit(0)
})
.catch(err => {
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,63 @@
require 'rubygems'
require 'recurly'
require 'json'
if ENV['RECURLY_SUBDOMAIN']
Recurly.subdomain = ENV['RECURLY_SUBDOMAIN']
else
print "Defaulting to sharelatex-sandbox. Set RECURLY_SUBDOMAIN environment variable to override\n"
Recurly.subdomain = "sharelatex-sandbox"
end
if ENV['RECURLY_API_KEY']
Recurly.api_key = ENV['RECURLY_API_KEY']
else
print "Please set RECURLY_API_KEY environment variable\n"
exit 1
end
file = File.read('../../app/templates/plans/groups.json')
groups = JSON.parse(file)
# data format: groups[usage][plan_code][currency][size] = price
PLANS = {}
groups.each do |usage, data|
data.each do |plan_code, data|
data.each do |currency, data|
data.each do |size, price|
full_plan_code = "group_#{plan_code}_#{size}_#{usage}"
plan = PLANS[full_plan_code] ||= {
plan_code: full_plan_code,
name: "Overleaf #{plan_code.capitalize} - Group Account (#{size} licenses) - #{usage.capitalize}",
unit_amount_in_cents: {},
plan_interval_length: 12,
plan_interval_unit: 'months',
tax_code: 'digital'
}
plan[:unit_amount_in_cents][currency] = price * 100
end
end
end
end
PLANS.each do |plan_code, plan|
print "Syncing #{plan_code}...\n"
print "#{plan}\n"
begin
recurly_plan = Recurly::Plan.find(plan_code)
rescue Recurly::Resource::NotFound => e
recurly_plan = nil
end
if recurly_plan.nil?
print "No plan found, creating...\n"
Recurly::Plan.create(plan)
else
print "Existing plan found, updating...\n"
plan.each do |key, value|
recurly_plan[key] = value
recurly_plan.save
end
end
print "Done!\n"
end

View File

@@ -0,0 +1,103 @@
import recurly from 'recurly'
import Settings from '@overleaf/settings'
import fs from 'node:fs'
import minimist from 'minimist'
import * as csv from 'csv'
import { setTimeout } from 'node:timers/promises'
const recurlyApiKey = Settings.apis.recurly.apiKey
if (!recurlyApiKey) {
throw new Error('Recurly API key is not set in the settings')
}
const client = new recurly.Client(recurlyApiKey)
function usage() {
console.error(
'Script to update terms and conditions for manually billed Recurly subscriptions'
)
console.error('')
console.error('Usage:')
console.error(
' node scripts/recurly/update_terms_and_conditions_for_manually_billed_users.mjs [options]'
)
console.error('')
console.error('Options:')
console.error(
' --input, -i <file> Path to CSV file containing subscription IDs (can be exported from Recurly)'
)
console.error(
' --termsAndConditions, -t <file> Path to text file containing terms and conditions'
)
console.error('')
console.error('Input format:')
console.error(
' - Subscription IDs CSV: First column contains subscription IDs (header row is skipped)'
)
console.error(
' - Terms and conditions: Plain text file with the terms and conditions content'
)
}
function parseArgs() {
return minimist(process.argv.slice(2), {
string: ['input', 'termsAndConditions'],
alias: {
i: 'input',
t: 'termsAndConditions',
},
})
}
async function updateTermsAndConditionsForSubscription(
subscriptionId,
termsAndConditions
) {
try {
await client.updateSubscription(`uuid-${subscriptionId}`, {
terms_and_conditions: termsAndConditions,
})
} catch (error) {
console.error(
`Error updating subscription ${subscriptionId}: ${error.message}`
)
}
}
async function main() {
const {
termsAndConditions: termsAndConditionsPath,
input: inputPath,
h,
help,
} = parseArgs()
if (help || h || !termsAndConditionsPath || !inputPath) {
usage()
process.exit(0)
}
const termsAndConditions = fs.readFileSync(termsAndConditionsPath, 'utf8')
const parser = csv.parse({ columns: true })
fs.createReadStream(inputPath).pipe(parser)
let processedCount = 0
for await (const row of parser) {
const subscriptionId = row.subscription_id
await updateTermsAndConditionsForSubscription(
subscriptionId,
termsAndConditions
)
processedCount++
if (processedCount % 10 === 0) {
console.log(`Processed ${processedCount} subscriptions`)
}
await setTimeout(1000)
}
console.log(`Processed ${processedCount} subscriptions in total`)
}
try {
await main()
process.exit(0)
} catch (error) {
console.error(error)
process.exit(1)
}