11import config from 'config' ;
22import fetch , { Response } from 'node-fetch' ;
33
4+ import FEATURE from '../../constants/feature' ;
45import logger from '../../lib/logger' ;
56import { getHostPaypalAccount } from '../../lib/paypal' ;
67import { reportMessageToSentry } from '../../lib/sentry' ;
78import { Collective } from '../../models' ;
89
10+ import { PaypalUserInfo } from './types' ;
11+
12+ /** PayPal scopes required by each platform feature */
13+ export const PAYPAL_SCOPE_REQUIREMENTS : Partial < Record < FEATURE , string [ ] > > = {
14+ [ FEATURE . PAYPAL_PAYOUTS ] : [ 'https://uri.paypal.com/services/payouts' ] ,
15+ [ FEATURE . PAYPAL_DONATIONS ] : [ 'https://uri.paypal.com/services/payments/payment/authcapture' ] ,
16+ } ;
17+
918/** Build an URL for the PayPal API */
1019export function paypalUrl ( path : string , version = 'v1' ) : string {
1120 if ( path . startsWith ( '/' ) ) {
@@ -19,6 +28,13 @@ export function paypalUrl(path: string, version = 'v1'): string {
1928 return new URL ( baseUrl + path ) . toString ( ) ;
2029}
2130
31+ /** Build the PayPal authorization URL for "Log in with PayPal" */
32+ export function paypalConnectAuthorizeUrl ( ) : string {
33+ return config . paypal . payment . environment === 'sandbox'
34+ ? 'https://www.sandbox.paypal.com/connect/'
35+ : 'https://www.paypal.com/connect/' ;
36+ }
37+
2238/** Exchange clientid and secretid by an auth token with PayPal API */
2339export async function retrieveOAuthToken ( { clientId, clientSecret } ) : Promise < string > {
2440 const url = paypalUrl ( 'oauth2/token' ) ;
@@ -33,6 +49,123 @@ export async function retrieveOAuthToken({ clientId, clientSecret }): Promise<st
3349 return jsonOutput . access_token ;
3450}
3551
52+ /**
53+ * Exchange an authorization code for a user access + refresh token using the platform PayPal Connect app.
54+ * Used in the "Log in with PayPal" flow.
55+ */
56+ export async function exchangeAuthCodeForToken ( code : string ) : Promise < {
57+ access_token : string ;
58+ refresh_token : string ;
59+ token_type : string ;
60+ expires_in : number ;
61+ scope : string ;
62+ nonce : string ;
63+ state : string ;
64+ } > {
65+ const url = paypalUrl ( 'oauth2/token' ) ;
66+ const authStr = `${ config . paypal . connect . clientId } :${ config . paypal . connect . clientSecret } ` ;
67+ const basicAuth = Buffer . from ( authStr ) . toString ( 'base64' ) ;
68+ const headers = { Authorization : `Basic ${ basicAuth } ` , 'Content-Type' : 'application/x-www-form-urlencoded' } ;
69+ const body = new URLSearchParams ( ) ;
70+ body . set ( 'grant_type' , 'authorization_code' ) ;
71+ body . set ( 'code' , code ) ;
72+ body . set ( 'redirect_uri' , config . paypal . connect . redirectUri ) ; // TODO: Should be auto-generated if missing
73+
74+ const response = await fetch ( url , { method : 'post' , body, headers } ) ;
75+ if ( ! response . ok ) {
76+ const error = await response . json ( ) . catch ( ( ) => ( { } ) ) ;
77+ throw new Error (
78+ `PayPal token exchange failed (${ response . status } ): ${ ( error as any ) . error_description || response . statusText } ` ,
79+ ) ;
80+ }
81+
82+ return response . json ( ) ;
83+ }
84+
85+ /**
86+ * Refresh a user's PayPal access token using their stored refresh token.
87+ */
88+ export async function refreshPaypalUserToken (
89+ refreshToken : string ,
90+ ) : Promise < { access_token : string ; refresh_token : string ; expires_in : number } > {
91+ const url = paypalUrl ( 'oauth2/token' ) ;
92+ const authStr = `${ config . paypal . connect . clientId } :${ config . paypal . connect . clientSecret } ` ;
93+ const basicAuth = Buffer . from ( authStr ) . toString ( 'base64' ) ;
94+ const headers = { Authorization : `Basic ${ basicAuth } ` , 'Content-Type' : 'application/x-www-form-urlencoded' } ;
95+ const body = new URLSearchParams ( ) ;
96+ body . set ( 'grant_type' , 'refresh_token' ) ;
97+ body . set ( 'refresh_token' , refreshToken ) ;
98+
99+ const response = await fetch ( url , { method : 'post' , body, headers } ) ;
100+ if ( ! response . ok ) {
101+ const error = await response . json ( ) . catch ( ( ) => ( { } ) ) ;
102+ throw new Error (
103+ `PayPal token refresh failed (${ response . status } ): ${ ( error as any ) . error_description || response . statusText } ` ,
104+ ) ;
105+ }
106+ return response . json ( ) ;
107+ }
108+
109+ /**
110+ * Retrieve the authenticated user's PayPal identity information using their access token.
111+ * Requires the `openid`, `email`, and `https://uri.paypal.com/services/paypalattributes` scopes.
112+ */
113+ export async function retrievePaypalUserInfo ( accessToken : string ) : Promise < PaypalUserInfo > {
114+ const url = `${ paypalUrl ( 'identity/oauth2/userinfo' ) } ?schema=paypalv1.1` ;
115+ const headers = { Authorization : `Bearer ${ accessToken } ` , 'Content-Type' : 'application/json' } ;
116+ const response = await fetch ( url , { method : 'get' , headers } ) ;
117+ if ( ! response . ok ) {
118+ const error = await response . json ( ) . catch ( ( ) => ( { } ) ) ;
119+ throw new Error (
120+ `PayPal userinfo request failed (${ response . status } ): ${ ( error as any ) . message || response . statusText } ` ,
121+ ) ;
122+ }
123+ return response . json ( ) ;
124+ }
125+
126+ /**
127+ * Retrieve the list of scopes granted to a host's PayPal application credentials.
128+ * Used to verify that required APIs are enabled on the PayPal account.
129+ */
130+ export async function retrieveGrantedScopes ( clientId : string , clientSecret : string ) : Promise < string [ ] > {
131+ const url = paypalUrl ( 'oauth2/token' ) ;
132+ const body = 'grant_type=client_credentials' ;
133+ const authStr = `${ clientId } :${ clientSecret } ` ;
134+ const basicAuth = Buffer . from ( authStr ) . toString ( 'base64' ) ;
135+ const headers = { Authorization : `Basic ${ basicAuth } ` , 'Content-Type' : 'application/x-www-form-urlencoded' } ;
136+ const response = await fetch ( url , { method : 'post' , body, headers } ) ;
137+ if ( ! response . ok ) {
138+ const error = await response . json ( ) . catch ( ( ) => ( { } ) ) ;
139+ throw new Error (
140+ `PayPal credentials check failed (${ response . status } ): ${ ( error as any ) . error_description || response . statusText } ` ,
141+ ) ;
142+ }
143+ const result = ( await response . json ( ) ) as { scope ?: string } ;
144+ return result . scope ? result . scope . split ( ' ' ) : [ ] ;
145+ }
146+
147+ /**
148+ * Check whether the granted scopes satisfy the requirements for the given set of enabled features.
149+ * Returns a list of missing scope descriptions for any unsatisfied features.
150+ */
151+ export function checkPaypalScopes (
152+ grantedScopes : string [ ] ,
153+ enabledFeatures : FEATURE [ ] ,
154+ ) : { feature : FEATURE ; missingScopes : string [ ] } [ ] {
155+ const issues : { feature : FEATURE ; missingScopes : string [ ] } [ ] = [ ] ;
156+ for ( const feature of enabledFeatures ) {
157+ const required = PAYPAL_SCOPE_REQUIREMENTS [ feature ] ;
158+ if ( ! required ) {
159+ continue ;
160+ }
161+ const missing = required . filter ( scope => ! grantedScopes . includes ( scope ) ) ;
162+ if ( missing . length > 0 ) {
163+ issues . push ( { feature, missingScopes : missing } ) ;
164+ }
165+ }
166+ return issues ;
167+ }
168+
36169const parsePaypalError = async (
37170 response : Response ,
38171 defaultMessage = 'PayPal request failed' ,
0 commit comments