|
| 1 | +/** |
| 2 | + * Daily CRON job to refresh PayPal user identity information for payees who have connected their |
| 3 | + * PayPal accounts via OAuth. This ensures that hosts always see up-to-date account information |
| 4 | + * (name, email, verification status) and helps detect disconnected or changed accounts. |
| 5 | + * |
| 6 | + * Accounts that were verified more than REFRESH_AFTER_DAYS days ago are refreshed. |
| 7 | + * Accounts where the refresh fails (e.g., token revoked) are flagged by clearing `data.verified`. |
| 8 | + * |
| 9 | + * See: https://github.com/opencollective/opencollective/issues/8382 |
| 10 | + */ |
| 11 | + |
| 12 | +import '../../server/env'; |
| 13 | + |
| 14 | +import moment from 'moment'; |
| 15 | +import { Op } from 'sequelize'; |
| 16 | + |
| 17 | +import logger from '../../server/lib/logger'; |
| 18 | +import { refreshPaypalUserAccount } from '../../server/lib/paypal'; |
| 19 | +import { HandlerType, reportErrorToSentry } from '../../server/lib/sentry'; |
| 20 | +import { ConnectedAccount } from '../../server/models'; |
| 21 | +import { runCronJob } from '../utils'; |
| 22 | + |
| 23 | +/** Refresh accounts that haven't been updated in this many days */ |
| 24 | +const REFRESH_AFTER_DAYS = Number(process.env.REFRESH_AFTER_DAYS) || 7; |
| 25 | +const DRY_RUN = process.env.DRY_RUN === 'true'; |
| 26 | +const BATCH_SIZE = 50; |
| 27 | + |
| 28 | +const run = async () => { |
| 29 | + const cutoff = moment.utc().subtract(REFRESH_AFTER_DAYS, 'days').toDate(); |
| 30 | + |
| 31 | + // Find user-level PayPal ConnectedAccounts (those without a clientId are user-level, not host-level) |
| 32 | + const accounts = await ConnectedAccount.findAll({ |
| 33 | + where: { |
| 34 | + service: 'paypal', |
| 35 | + clientId: null, |
| 36 | + refreshToken: { [Op.not]: null }, |
| 37 | + updatedAt: { [Op.lt]: cutoff }, |
| 38 | + }, |
| 39 | + order: [['updatedAt', 'ASC']], |
| 40 | + limit: BATCH_SIZE, |
| 41 | + }); |
| 42 | + |
| 43 | + logger.info( |
| 44 | + `Found ${accounts.length} PayPal user ConnectedAccount(s) due for refresh (last updated before ${cutoff.toISOString()})`, |
| 45 | + ); |
| 46 | + |
| 47 | + if (DRY_RUN) { |
| 48 | + logger.info('[DRY_RUN] Would refresh the above accounts. Exiting.'); |
| 49 | + return; |
| 50 | + } |
| 51 | + |
| 52 | + let refreshed = 0; |
| 53 | + let failed = 0; |
| 54 | + |
| 55 | + for (const account of accounts) { |
| 56 | + try { |
| 57 | + const result = await refreshPaypalUserAccount(account); |
| 58 | + if (result) { |
| 59 | + refreshed++; |
| 60 | + logger.debug(`Refreshed PayPal ConnectedAccount #${account.id} (Collective #${account.CollectiveId})`); |
| 61 | + } else { |
| 62 | + failed++; |
| 63 | + // refreshPaypalUserAccount returned null — mark as not verified so the payee is prompted to reconnect |
| 64 | + await account.update({ |
| 65 | + data: { |
| 66 | + ...account.data, |
| 67 | + verified: false, |
| 68 | + refreshFailedAt: new Date().toISOString(), |
| 69 | + }, |
| 70 | + }); |
| 71 | + logger.warn( |
| 72 | + `Failed to refresh PayPal ConnectedAccount #${account.id} (Collective #${account.CollectiveId}) — marked as unverified`, |
| 73 | + ); |
| 74 | + } |
| 75 | + } catch (err) { |
| 76 | + failed++; |
| 77 | + logger.error(`Error refreshing PayPal ConnectedAccount #${account.id}: ${err.message}`); |
| 78 | + reportErrorToSentry(err, { |
| 79 | + handler: HandlerType.CRON, |
| 80 | + extra: { connectedAccountId: account.id, collectiveId: account.CollectiveId }, |
| 81 | + }); |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + logger.info( |
| 86 | + `PayPal user account refresh complete: ${refreshed} refreshed, ${failed} failed out of ${accounts.length} accounts`, |
| 87 | + ); |
| 88 | +}; |
| 89 | + |
| 90 | +if (require.main === module) { |
| 91 | + runCronJob('refresh-paypal-user-accounts', run, 24 * 60 * 60); |
| 92 | +} |
0 commit comments