Skip to content

Commit f6d1a68

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

File tree

2 files changed

+133
-0
lines changed

2 files changed

+133
-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+
}

server/lib/paypal.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,44 @@ export async function listPayPalTransactions(
388388
}
389389

390390
export { paypal };
391+
392+
/**
393+
* Refresh a payee's PayPal user ConnectedAccount using the stored refresh token.
394+
* Updates the access token, refresh token, and re-fetches identity information from PayPal.
395+
* Returns the updated ConnectedAccount, or null if the refresh failed (e.g. account was disconnected).
396+
*/
397+
export const refreshPaypalUserAccount = async (
398+
connectedAccount: ConnectedAccount,
399+
): Promise<ConnectedAccount | null> => {
400+
const { refreshPaypalUserToken, retrievePaypalUserInfo } = await import('../paymentProviders/paypal/api');
401+
402+
const storedRefreshToken = connectedAccount.refreshToken;
403+
if (!storedRefreshToken) {
404+
logger.warn(`refreshPaypalUserAccount: ConnectedAccount #${connectedAccount.id} has no refresh token — skipping`);
405+
return null;
406+
}
407+
408+
try {
409+
const tokenResult = await refreshPaypalUserToken(storedRefreshToken);
410+
const userInfo = await retrievePaypalUserInfo(tokenResult.access_token);
411+
412+
await connectedAccount.update({
413+
token: tokenResult.access_token,
414+
refreshToken: tokenResult.refresh_token,
415+
username: userInfo.email,
416+
data: {
417+
...connectedAccount.data,
418+
payerId: userInfo.user_id,
419+
verified: userInfo.verified_account,
420+
name: userInfo.name,
421+
email: userInfo.email,
422+
verifiedAt: new Date().toISOString(),
423+
},
424+
});
425+
426+
return connectedAccount;
427+
} catch (e) {
428+
logger.error(`refreshPaypalUserAccount: failed to refresh ConnectedAccount #${connectedAccount.id}${e.message}`);
429+
return null;
430+
}
431+
};

0 commit comments

Comments
 (0)