Skip to content

Commit 6b1a80a

Browse files
author
Benjamin Piouffle
committed
feat(PayPal): CRON to update identity data
1 parent 5e374c1 commit 6b1a80a

File tree

1 file changed

+92
-0
lines changed

1 file changed

+92
-0
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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

Comments
 (0)