| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789 |
- import fs from 'node:fs'
- import { Parser as CSVParser } from 'json2csv'
- import minimist from 'minimist'
- import pLimit from 'p-limit'
- import Settings from '@overleaf/settings'
- import { scriptRunner } from './lib/ScriptRunner.mjs'
- import {
- db,
- ObjectId,
- READ_PREFERENCE_SECONDARY,
- } from '../app/src/infrastructure/mongodb.mjs'
- import PaymentService from '../modules/subscriptions/app/src/PaymentService.mjs'
- import FeaturesHelper from '../app/src/Features/Subscription/FeaturesHelper.mjs'
- import CustomerIoPlanHelpers from '../app/src/Features/Subscription/CustomerIoPlanHelpers.mjs'
- import { isStandaloneAiAddOnPlanCode } from '../app/src/Features/Subscription/AiHelper.mjs'
- import InstitutionsGetter from '../app/src/Features/Institutions/InstitutionsGetter.mjs'
- const CSV_FIELDS = [
- { label: 'user_id', value: 'userId' },
- { label: 'email', value: 'email' },
- { label: 'plan_type', value: 'planType' },
- { label: 'display_plan_type', value: 'displayPlanType' },
- {
- label: 'pre_migration_plan_type',
- value: 'preMigrationPlanType',
- },
- {
- label: 'pre_migration_display_plan_type',
- value: 'preMigrationDisplayPlanType',
- },
- { label: 'plan_term', value: 'planTerm' },
- { label: 'ai_plan', value: 'aiPlan' },
- { label: 'ai_plan_term', value: 'aiPlanTerm' },
- { label: 'next_renewal_date', value: 'nextRenewalDate' },
- { label: 'expiry_date', value: 'expiryDate' },
- { label: 'group_ai_enabled', value: 'groupAIEnabled' },
- { label: 'group_role', value: 'groupRole' },
- ]
- const CSV_FIELD_NAMES = CSV_FIELDS.map(field => field.value)
- const ACTIVE_SUBSCRIPTION_STATES = ['active', 'trialing']
- const COMMONS_PLAN = getPlan(Settings.institutionPlanCode)
- function usage() {
- console.log(`
- Usage:
- node scripts/export_active_subscription_users_csv.mjs [options]
- Options:
- --outputPath <path> Output CSV path (default: /tmp/active_subscription_users.csv)
- --concurrency <number> Concurrent payment-provider lookups (default: 5)
- --batchSize <number> Number of users processed per batch (default: 500)
- --resumeAfterUserId <id> Resume processing strictly after this user id
- --checkpointInterval <n> Log resumable checkpoint every N processed users (default: 10000)
- --append Append to existing output file (for resume runs)
- --help Show this message
- `)
- }
- function parseArgs() {
- const args = minimist(process.argv.slice(2), {
- string: ['outputPath', 'resumeAfterUserId'],
- boolean: ['help', 'append'],
- default: {
- outputPath: '/tmp/active_subscription_users.csv',
- concurrency: 5,
- batchSize: 500,
- checkpointInterval: 10000,
- append: false,
- help: false,
- },
- })
- if (args.help) {
- usage()
- process.exit(0)
- }
- return args
- }
- function getPlan(planCode) {
- return Settings.plans.find(plan => plan.planCode === planCode) || null
- }
- function getPlanType(subscription) {
- if (isStandaloneAiAddOnPlanCode(subscription.planCode)) {
- return 'standalone-ai-add-on'
- }
- return subscription.groupPlan ? 'group' : 'individual'
- }
- function getPlanCadence(subscription, plan) {
- if (plan != null) {
- return plan.annual ? 'annual' : 'monthly'
- }
- if (isStandaloneAiAddOnPlanCode(subscription.planCode)) {
- return subscription.planCode.includes('annual') ? 'annual' : 'monthly'
- }
- return ''
- }
- function userHasPremiumAiFeatures(user) {
- return (
- user?.features?.aiErrorAssistant === true ||
- user?.features?.aiUsageQuota === Settings.aiFeatures.unlimitedQuota
- )
- }
- async function userHasCurrentInstitutionLicence(userId, commonsCache) {
- if (commonsCache.has(userId)) {
- return commonsCache.get(userId)
- }
- try {
- const institutions =
- await InstitutionsGetter.promises.getCurrentInstitutionsWithLicence(
- userId
- )
- const hasCommons = Boolean(institutions?.length)
- commonsCache.set(userId, hasCommons)
- return hasCommons
- } catch (error) {
- console.warn(
- `Failed to evaluate commons licence for user ${userId}: ${error.message}`
- )
- commonsCache.set(userId, false)
- return false
- }
- }
- function isMemberOfGroupSubscription(candidates) {
- return candidates.some(candidate => candidate.subscription?.groupPlan)
- }
- function getAiPlanForUser({
- bestSubscription,
- planType,
- individualSubscription,
- paymentRecord,
- user,
- userIsMemberOfGroupSubscription,
- userHasActiveOverleafSubscription,
- }) {
- const baseAiPlan = CustomerIoPlanHelpers.getAiPlanType(
- bestSubscription,
- individualSubscription,
- paymentRecord,
- user?.writefull,
- userIsMemberOfGroupSubscription
- )
- if (baseAiPlan !== 'none') {
- return baseAiPlan
- }
- if (!userHasActiveOverleafSubscription && userHasPremiumAiFeatures(user)) {
- return 'ai-assist'
- }
- return 'none'
- }
- function getPaymentState(subscription) {
- if (subscription?.recurlyStatus?.state) {
- return subscription.recurlyStatus.state
- }
- if (subscription?.paymentProvider?.state) {
- return subscription.paymentProvider.state
- }
- return null
- }
- function isActivePaidSubscription(subscription) {
- const hasRecurlySubscription = Boolean(subscription?.recurlySubscription_id)
- const hasStripeSubscription = Boolean(
- subscription?.paymentProvider?.subscriptionId
- )
- if (!hasRecurlySubscription && !hasStripeSubscription) {
- return false
- }
- return ACTIVE_SUBSCRIPTION_STATES.includes(getPaymentState(subscription))
- }
- function getGroupAiEnabled(candidates) {
- const groupCandidates = candidates.filter(
- candidate => getPlanType(candidate.subscription) === 'group'
- )
- if (groupCandidates.length === 0) {
- return ''
- }
- return groupCandidates.some(candidate =>
- CustomerIoPlanHelpers.hasPlanAiEnabled(candidate.plan)
- )
- }
- function getGroupRole(candidates, userId) {
- const groupCandidates = candidates.filter(
- candidate => candidate.subscription?.groupPlan
- )
- if (groupCandidates.length === 0) {
- return ''
- }
- const isGroupAdminOrManager = groupCandidates.some(candidate => {
- const subscription = candidate.subscription
- const adminId = subscription?.admin_id?.toString()
- const managerIds = (subscription?.manager_ids || []).map(id =>
- id?.toString()
- )
- return adminId === userId || managerIds.includes(userId)
- })
- return isGroupAdminOrManager ? 'admin' : 'member'
- }
- function chooseBestCandidate(candidates) {
- let best = null
- for (const candidate of candidates) {
- if (best == null) {
- best = candidate
- continue
- }
- const candidateType = getPlanType(candidate.subscription)
- const bestType = getPlanType(best.subscription)
- if (candidateType === 'standalone-ai-add-on' && bestType !== 'free') {
- continue
- }
- if (
- candidateType !== 'standalone-ai-add-on' &&
- bestType === 'standalone-ai-add-on'
- ) {
- best = candidate
- continue
- }
- if (
- FeaturesHelper.isFeatureSetBetter(
- candidate.plan?.features || {},
- best.plan?.features || {}
- )
- ) {
- best = candidate
- }
- }
- return best
- }
- function getSubscriptionQuery() {
- return {
- $or: [
- {
- recurlySubscription_id: { $exists: true, $nin: ['', null] },
- 'recurlyStatus.state': { $in: ACTIVE_SUBSCRIPTION_STATES },
- },
- {
- 'paymentProvider.subscriptionId': { $exists: true, $nin: ['', null] },
- 'paymentProvider.state': { $in: ACTIVE_SUBSCRIPTION_STATES },
- },
- ],
- }
- }
- function getSubscriptionProjection() {
- return {
- _id: 1,
- admin_id: 1,
- manager_ids: 1,
- member_ids: 1,
- planCode: 1,
- groupPlan: 1,
- recurlySubscription_id: 1,
- recurlyStatus: 1,
- paymentProvider: 1,
- addOns: 1,
- }
- }
- function getSupplementaryUserQuery() {
- return {
- $or: [
- { 'writefull.isPremium': true },
- { 'features.aiErrorAssistant': true },
- { 'features.aiUsageQuota': Settings.aiFeatures.unlimitedQuota },
- {
- emails: {
- $elemMatch: {
- confirmedAt: { $exists: true },
- 'affiliation.institution.confirmed': true,
- 'affiliation.licence': { $exists: true, $ne: 'free' },
- 'affiliation.pastReconfirmDate': { $ne: true },
- },
- },
- },
- ],
- }
- }
- function getTargetUserIdsPipeline(resumeAfterUserId) {
- const pipeline = [
- { $match: getSubscriptionQuery() },
- {
- $project: {
- participantIds: {
- $setUnion: [
- [{ $ifNull: ['$admin_id', null] }],
- { $ifNull: ['$manager_ids', []] },
- { $ifNull: ['$member_ids', []] },
- ],
- },
- },
- },
- { $unwind: '$participantIds' },
- { $match: { participantIds: { $ne: null } } },
- { $group: { _id: '$participantIds' } },
- {
- $unionWith: {
- coll: 'users',
- pipeline: [
- { $match: getSupplementaryUserQuery() },
- { $project: { _id: 1 } },
- ],
- },
- },
- { $group: { _id: '$_id' } },
- ]
- if (resumeAfterUserId) {
- let resumeId = resumeAfterUserId
- try {
- resumeId = new ObjectId(resumeAfterUserId)
- } catch {
- // leave as-is for non-ObjectId identifiers
- }
- pipeline.push({ $match: { _id: { $gt: resumeId } } })
- }
- pipeline.push({ $sort: { _id: 1 } })
- return pipeline
- }
- function getTargetUserIdsCursor(resumeAfterUserId) {
- return db.subscriptions.aggregate(
- getTargetUserIdsPipeline(resumeAfterUserId),
- {
- allowDiskUse: true,
- readPreference: READ_PREFERENCE_SECONDARY,
- }
- )
- }
- function isInvalidOrMissingSubscriptionError(error) {
- const message = String(error?.message || '').toLowerCase()
- return (
- message.includes('no such subscription') ||
- message.includes('invalid subscription') ||
- message.includes('subscription not found')
- )
- }
- async function getSubscriptionPaymentInfo(
- userId,
- subscription,
- paymentInfoCache
- ) {
- const subscriptionId = subscription._id.toString()
- if (paymentInfoCache.has(subscriptionId)) {
- return {
- ...paymentInfoCache.get(subscriptionId),
- skip: false,
- }
- }
- try {
- const paymentRecord =
- await PaymentService.promises.getPaymentFromRecord(subscription)
- const nextRenewalDate =
- CustomerIoPlanHelpers.getNextRenewalDateFromPaymentRecord(
- paymentRecord
- ) || ''
- const paymentInfo = { nextRenewalDate, paymentRecord }
- paymentInfoCache.set(subscriptionId, paymentInfo)
- return { ...paymentInfo, skip: false }
- } catch (error) {
- if (isInvalidOrMissingSubscriptionError(error)) {
- console.warn(
- `Skipping user ${userId}: invalid/missing payment-provider subscription for subscription ${subscriptionId} (${error.message})`
- )
- return { nextRenewalDate: '', paymentRecord: null, skip: true }
- }
- console.error(
- `Failed to get renewal date for subscription ${subscriptionId}:`,
- error.message
- )
- const paymentInfo = { nextRenewalDate: '', paymentRecord: null }
- paymentInfoCache.set(subscriptionId, paymentInfo)
- return { ...paymentInfo, skip: false }
- }
- }
- async function getUsersByIds(userIds) {
- const objectIds = []
- for (const userId of userIds) {
- try {
- objectIds.push(new ObjectId(userId))
- } catch {
- // ignore invalid ObjectId strings
- }
- }
- const idFilters = []
- if (objectIds.length > 0) {
- idFilters.push({ _id: { $in: objectIds } })
- }
- if (userIds.length > 0) {
- idFilters.push({ _id: { $in: userIds } })
- }
- if (idFilters.length === 0) {
- return new Map()
- }
- const users = await db.users
- .find(idFilters.length === 1 ? idFilters[0] : { $or: idFilters }, {
- projection: {
- _id: 1,
- email: 1,
- features: 1,
- writefull: 1,
- },
- readPreference: READ_PREFERENCE_SECONDARY,
- })
- .toArray()
- return new Map(users.map(user => [user._id.toString(), user]))
- }
- async function getActiveSubscriptionsForUserIds(userIds) {
- const objectIds = []
- for (const userId of userIds) {
- try {
- objectIds.push(new ObjectId(userId))
- } catch {
- // ignore invalid ObjectId strings
- }
- }
- const idValues = objectIds.length > 0 ? objectIds : userIds
- return await db.subscriptions
- .find(
- {
- ...getSubscriptionQuery(),
- $or: [
- { admin_id: { $in: idValues } },
- { manager_ids: { $in: idValues } },
- { member_ids: { $in: idValues } },
- ],
- },
- {
- projection: getSubscriptionProjection(),
- readPreference: READ_PREFERENCE_SECONDARY,
- }
- )
- .toArray()
- }
- function buildUserCandidatesMap(subscriptions, allowedUserIds) {
- const userCandidates = new Map()
- const allowedUserIdsSet = new Set(allowedUserIds)
- function addCandidate(userId, candidate) {
- if (!userId || !allowedUserIdsSet.has(userId)) {
- return
- }
- if (!userCandidates.has(userId)) {
- userCandidates.set(userId, [])
- }
- userCandidates.get(userId).push(candidate)
- }
- for (const subscription of subscriptions) {
- const plan = getPlan(subscription.planCode)
- const baseCandidate = { subscription, plan }
- const adminId = subscription.admin_id?.toString()
- addCandidate(adminId, baseCandidate)
- if (Array.isArray(subscription.manager_ids)) {
- for (const managerIdRaw of subscription.manager_ids) {
- addCandidate(managerIdRaw?.toString(), baseCandidate)
- }
- }
- if (subscription.groupPlan && Array.isArray(subscription.member_ids)) {
- for (const memberIdRaw of subscription.member_ids) {
- addCandidate(memberIdRaw?.toString(), baseCandidate)
- }
- }
- }
- return userCandidates
- }
- function writeCsvRows(writeStream, rows, includeHeader) {
- if (rows.length === 0) {
- return
- }
- const csvParser = new CSVParser({
- fields: includeHeader ? CSV_FIELDS : CSV_FIELD_NAMES,
- header: includeHeader,
- eol: '\n',
- })
- writeStream.write(`${csvParser.parse(rows)}\n`)
- }
- function writeCsvHeader(writeStream) {
- writeStream.write(`${CSV_FIELDS.map(field => field.label).join(',')}\n`)
- }
- async function processUserBatch({
- userIds,
- writeStream,
- paymentInfoCache,
- commonsCache,
- limit,
- trackProgress,
- totalUsersCount,
- globalState,
- checkpointInterval,
- }) {
- const usersById = await getUsersByIds(userIds)
- const subscriptions = await getActiveSubscriptionsForUserIds(userIds)
- const activeSubscriptions = subscriptions.filter(isActivePaidSubscription)
- const userCandidates = buildUserCandidatesMap(activeSubscriptions, userIds)
- const rows = await Promise.all(
- userIds.map(userId =>
- limit(async () => {
- const user = usersById.get(userId)
- if (!user?.email) {
- return null
- }
- const candidates = userCandidates.get(userId) || []
- const bestCandidate = chooseBestCandidate(candidates)
- const hasCommons = await userHasCurrentInstitutionLicence(
- userId,
- commonsCache
- )
- const commonsBeatsBestSubscription =
- CustomerIoPlanHelpers.shouldUseCommonsBestSubscription(
- hasCommons,
- bestCandidate,
- COMMONS_PLAN
- )
- const resolvedSubscription = commonsBeatsBestSubscription
- ? null
- : bestCandidate?.subscription
- const resolvedPlan = commonsBeatsBestSubscription
- ? COMMONS_PLAN
- : bestCandidate?.plan
- const bestSubscriptionForPlanType = commonsBeatsBestSubscription
- ? { type: 'commons', plan: COMMONS_PLAN }
- : resolvedSubscription
- ? {
- type: getPlanType(resolvedSubscription),
- plan: resolvedPlan,
- }
- : { type: 'free' }
- const planType = CustomerIoPlanHelpers.normalizePlanType(
- bestSubscriptionForPlanType
- )
- const displayPlanType =
- CustomerIoPlanHelpers.getFriendlyPlanName(planType) || ''
- const planTerm = resolvedSubscription
- ? getPlanCadence(resolvedSubscription, resolvedPlan)
- : ''
- const userIsMemberOfGroupSubscription =
- isMemberOfGroupSubscription(candidates)
- const userHasActiveOverleafSubscription = bestCandidate != null
- let nextRenewalDate = ''
- let expiryDate = ''
- let paymentRecord = null
- if (resolvedSubscription) {
- const paymentInfo = await getSubscriptionPaymentInfo(
- userId,
- resolvedSubscription,
- paymentInfoCache
- )
- if (paymentInfo.skip) {
- return null
- }
- nextRenewalDate = paymentInfo.nextRenewalDate
- paymentRecord = paymentInfo.paymentRecord
- expiryDate =
- CustomerIoPlanHelpers.getExpiryDateFromPaymentRecord(
- paymentRecord
- ) || ''
- }
- const aiPlan = getAiPlanForUser({
- bestSubscription: bestSubscriptionForPlanType,
- planType,
- individualSubscription: resolvedSubscription,
- paymentRecord,
- user,
- userIsMemberOfGroupSubscription,
- userHasActiveOverleafSubscription,
- })
- const aiPlanTerm = CustomerIoPlanHelpers.getAiPlanCadence(
- aiPlan,
- bestSubscriptionForPlanType,
- resolvedSubscription,
- paymentRecord
- )
- const groupAIEnabled = getGroupAiEnabled(candidates)
- const groupRole = getGroupRole(candidates, userId)
- return {
- userId,
- email: user.email,
- planType,
- displayPlanType,
- preMigrationPlanType: planType,
- preMigrationDisplayPlanType: displayPlanType,
- planTerm,
- aiPlan,
- aiPlanTerm: aiPlanTerm || '',
- nextRenewalDate,
- expiryDate,
- groupAIEnabled,
- groupRole,
- }
- })
- )
- )
- const csvRows = []
- for (const row of rows) {
- globalState.processedCount += 1
- if (row) {
- csvRows.push(row)
- globalState.writtenCount += 1
- } else {
- globalState.skippedCount += 1
- }
- }
- writeCsvRows(writeStream, csvRows, globalState.shouldWriteHeader)
- if (csvRows.length > 0) {
- globalState.shouldWriteHeader = false
- }
- globalState.lastProcessedUserId = userIds[userIds.length - 1]
- if (globalState.processedCount % checkpointInterval < userIds.length) {
- await trackProgress(
- `Checkpoint: processed=${globalState.processedCount}/${totalUsersCount || '?'} written=${globalState.writtenCount} skipped=${globalState.skippedCount} resumeAfterUserId=${globalState.lastProcessedUserId}`
- )
- }
- }
- async function main(trackProgress) {
- const {
- outputPath,
- concurrency,
- batchSize,
- resumeAfterUserId,
- checkpointInterval,
- append,
- } = parseArgs()
- await trackProgress(
- 'Building target user cursor (subscriptions + supplementary users)'
- )
- if (resumeAfterUserId) {
- await trackProgress(
- `Resume enabled: starting after user id ${resumeAfterUserId}`
- )
- }
- const paymentInfoCache = new Map()
- const commonsCache = new Map()
- const limit = pLimit(Number(concurrency) || 5)
- const resolvedBatchSize = Math.max(1, Number(batchSize) || 500)
- const resolvedCheckpointInterval = Math.max(
- 100,
- Number(checkpointInterval) || 10000
- )
- const outputFileExists = fs.existsSync(outputPath)
- const outputFileHasContent =
- outputFileExists && fs.statSync(outputPath).size > 0
- const writeStream = fs.createWriteStream(outputPath, {
- flags: append ? 'a' : 'w',
- })
- const globalState = {
- processedCount: 0,
- writtenCount: 0,
- skippedCount: 0,
- lastProcessedUserId: resumeAfterUserId || null,
- shouldWriteHeader: !append || !outputFileHasContent,
- }
- let pendingUserIds = []
- const targetUserIdsCursor = getTargetUserIdsCursor(resumeAfterUserId)
- for await (const doc of targetUserIdsCursor) {
- pendingUserIds.push(doc._id.toString())
- if (pendingUserIds.length >= resolvedBatchSize) {
- await processUserBatch({
- userIds: pendingUserIds,
- writeStream,
- paymentInfoCache,
- commonsCache,
- limit,
- trackProgress,
- totalUsersCount: null,
- globalState,
- checkpointInterval: resolvedCheckpointInterval,
- })
- pendingUserIds = []
- }
- }
- if (pendingUserIds.length > 0) {
- await processUserBatch({
- userIds: pendingUserIds,
- writeStream,
- paymentInfoCache,
- commonsCache,
- limit,
- trackProgress,
- totalUsersCount: null,
- globalState,
- checkpointInterval: resolvedCheckpointInterval,
- })
- }
- if (globalState.shouldWriteHeader) {
- writeCsvHeader(writeStream)
- globalState.shouldWriteHeader = false
- }
- writeStream.end()
- await trackProgress(
- `Final checkpoint: processed=${globalState.processedCount} written=${globalState.writtenCount} skipped=${globalState.skippedCount} resumeAfterUserId=${globalState.lastProcessedUserId}`
- )
- await trackProgress(`CSV generated: ${outputPath}`)
- console.log(`✅ Export complete: ${outputPath}`)
- console.log(`Rows written: ${globalState.writtenCount}`)
- }
- try {
- await scriptRunner(main)
- process.exit(0)
- } catch (error) {
- console.error(error)
- process.exit(1)
- }
|