first commit
This commit is contained in:
4
services/web/scripts/recurly/Gemfile
Normal file
4
services/web/scripts/recurly/Gemfile
Normal file
@@ -0,0 +1,4 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
gem 'recurly'
|
||||
gem 'json'
|
230
services/web/scripts/recurly/change_prices_at_renewal.mjs
Normal file
230
services/web/scripts/recurly/change_prices_at_renewal.mjs
Normal 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)
|
||||
}
|
131
services/web/scripts/recurly/collect_paypal_past_due_invoice.mjs
Normal file
131
services/web/scripts/recurly/collect_paypal_past_due_invoice.mjs
Normal 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 }
|
55
services/web/scripts/recurly/generate_addon_prices.mjs
Normal file
55
services/web/scripts/recurly/generate_addon_prices.mjs
Normal 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()
|
121
services/web/scripts/recurly/generate_recurly_prices.mjs
Normal file
121
services/web/scripts/recurly/generate_recurly_prices.mjs
Normal 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')
|
||||
}
|
@@ -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)
|
||||
}
|
107
services/web/scripts/recurly/get_paypal_accounts_csv.mjs
Normal file
107
services/web/scripts/recurly/get_paypal_accounts_csv.mjs
Normal 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()
|
||||
})
|
47
services/web/scripts/recurly/get_recurly_group_prices.mjs
Normal file
47
services/web/scripts/recurly/get_recurly_group_prices.mjs
Normal 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)
|
||||
}
|
226
services/web/scripts/recurly/recurly_prices.mjs
Normal file
226
services/web/scripts/recurly/recurly_prices.mjs
Normal 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'
|
||||
)
|
||||
}
|
186
services/web/scripts/recurly/resync_subscriptions.mjs
Normal file
186
services/web/scripts/recurly/resync_subscriptions.mjs
Normal 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()
|
@@ -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 } }
|
||||
)
|
||||
}
|
219
services/web/scripts/recurly/setup_assistant_addon.js
Normal file
219
services/web/scripts/recurly/setup_assistant_addon.js
Normal 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)
|
||||
})
|
63
services/web/scripts/recurly/sync_recurly.rb
Normal file
63
services/web/scripts/recurly/sync_recurly.rb
Normal 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
|
@@ -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)
|
||||
}
|
Reference in New Issue
Block a user