PayPal のREST APIでログインやEC決済を試してみる

PayPalは、EC向けにいろんなツール類を用意してくれているのですが、ドキュメントが散在してわかりにくかったり、古いものから新しいものまでごちゃまぜになっている印象があります。

そんな中で、Node.jsの勉強のためもあり、REST APIを使ってみたいと思います。
割と今風で、マルチプラットフォームに対応できそうなので、これを選びました。
（これに慣れたら、最新のBraintreeに移行したいと思っています。）

（情報源）
PayPal Developer
　https://developer.paypal.com/

PayPal API Reference
　https://developer.paypal.com/docs/api/overview/

node.js SDK for PayPal RESTful APIs
　https://github.com/paypal/PayPal-node-SDK

Connect With PayPal
　https://developer.paypal.com/docs/integration/direct/identity/

他の決済サーバと同じように、自由にいじれるSandboxがあるので、気兼ねなく試してみましょう。

毎度のことですが、Swaggerを使ったRESTfulサーバを立ち上げます。
以下ご参考まで。

[SwaggerでRESTful環境を構築する](https://qiita.com/poruruba/items/72d8c85f8923bc78b918)

# PayPal Developerアカウントの作成

まずは、以下のページからアカウントを作成しましょう。

PayPal Developer
　https://developer.paypal.com/

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/e02d9ded-fd9b-efe0-811a-de6ee4087546.png)

まず見ていただきたいのが、Sandbox Accountです。
左側のナビゲータから選択します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/ca3a65f8-b68a-a7d3-fecf-e8c48d65d4ca.png)

あらかじめ2つのアカウントができているかと思います。

　XXXXX-facilitator@YYYYY.yyy BUSINESS
　XXXXX-buyer@YYYYY.yyy PERSONAL

これらアカウントは、PayPal Sandboxの中だけのアカウントですので、メールアドレスは実在しませんし、本番PayPalには使えません。
XXXXX-buyer@YYYYY.yyy は、あとでログインに使うので、パスワードを変えておきましょう。


# REST APIを使うための準備

いくつかの種類がありますが、今回使うのは、REST API appsです。
「Create App」ボタンを押下します。

App Nameには、適当な名前を付けます。例えば、「TestApp」とします。
Sandbox developer account には、BUSINESSのアカウントを指定するのですが、今回は、あらかじめ作られていた XXXXX-facilitator@YYYYY.yyy を選択します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/d7f2fbb3-292c-7e85-6236-a06d8b9ffd94.png)

そうすると、Client IDやSecretが表示されます。
後で使うので、覚えておきます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/ffdbbec1-13d5-8ddd-b52c-0049ad2c9311.png)

また、右上のところで、SandboxとLiveを選択できるのですが、当然ながら試作中なので、Sandboxにしておきます。


# RESTfulサーバを立ち上げる

では早速、サーバを立ち上げます。

2つのエンドポイントを使います。
Swagger定義を示します。

```:swagger.yaml
  /paypal-create:
    get:
      x-swagger-router-controller: routing
      operationId: paypal-create
      responses:
        200:
          description: Success
          schema:
            type: object

  /paypal-redirect:
    get:
      x-swagger-router-controller: routing
      operationId: paypal-redirect
      responses:
        200:
          description: Success
          schema:
            type: object
```

/paypal-create
　決済を開始します。品目や金額を指定します。例として、100円のお買い物です。
　その後、PayPalがユーザからApproveをもらうために、画面を表示してくれます。

/paypal-redirect
　PayPalの画面でユーザが承認してくれると、このエンドポイントが呼び出されます。
　Approveをもらったので、これで決済を完了させます。
　完了後、ユーザにPaymentIdなど、もろもろ返してあげています。

利用するnpmモジュールは以下の通りです。

* paypal-rest-sdk
* node-fetch
* uuid

PayPalから、paypal-rest-sdkというモジュールを提供していただいているので、だいぶ楽になりました。

(エンドポイント「/paypal-token」もありますが、これは後程説明します。)

```:index.js
var paypal = require('paypal-rest-sdk');

const base_url = 【RESTfulサーバを立ち上げているURL】;

var Response = require('../../helpers/response');
var Redirect = require('../../helpers/redirect');
var fetch = require('node-fetch');
const { URLSearchParams } = require('url');
const uuidv4 = require('uuid/v4');

const PAYPAL_CLIENT_ID = process.env.PAYPAL_CLIENT_ID || 【REST API appsのClient ID】;
const PAYPAL_CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET || 【REST API appsのSecret】;

paypal.configure({
    'mode': 'sandbox', //sandbox or live
    'client_id': PAYPAL_CLIENT_ID,
    'client_secret': PAYPAL_CLIENT_SECRET,
    'headers' : {
		'custom': 'header'
    }
});

exports.handler = async (event, context, callback) => {
    if( event.path == '/paypal-token'){
        var body = JSON.parse(event.body);
        var code = body.code;

        var url = 'https://api.sandbox.paypal.com/v1/oauth2/token';
        var params = {
            grant_type: 'authorization_code',
            code: code
        };

        return do_post_form_basic(url, params, PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET )
        .then(json =>{
            console.log(json);
            if( json.error )
                throw json.error;
            return do_get_token('https://api.sandbox.paypal.com/v1/identity/oauth2/userinfo', { schema: 'paypalv1.1'}, json.access_token);
        })
        .then(json =>{
            console.log(json);
            if( json.error )
                throw json.error;
            return new Response( json );
        })
        .catch(error =>{
            return new Response().set_error(error);
        })
    }else if( event.path == '/paypal-create'){
        var orderid = uuidv4();

        var create_payment_json = {
            "intent": "authorize",
            "payer": {
                "payment_method": "paypal"
            },
            "redirect_urls": {
                "return_url": base_url + "/paypal-redirect",
                "cancel_url": base_url + "/paypal/index.html"
            },
            "transactions": [{
                "custom": JSON.stringify({ orderid: orderid, amount: 100 } ),
                "item_list": {
                    "items": [{
                        "name": "item",
                        "price": "100",
                        "currency": "JPY",
                        "quantity": 1
                    }]
                },
                "amount": {
                    "currency": "JPY",
                    "total": "100"
                },
                "description": "This is the payment description."
            }]
        };

        return new Promise((resolve, reject) =>{
            paypal.payment.create(create_payment_json, (error, payment) => {
                if (error) {
                    console.log(error.response);
                    return reject(error);
                }
                console.log(payment);

                for (var index = 0; index < payment.links.length; index++) {
                    if (payment.links[index].rel === 'approval_url')
                        return resolve( new Redirect(payment.links[index].href) );
                }

                return reject('approval_url not found');
            });
        });
    }else if( event.path == '/paypal-redirect'){
        var qs = event.queryStringParameters;
        console.log(qs);

        var payerid = event.queryStringParameters.PayerID;
        var paymentid = event.queryStringParameters.paymentId;

        var execute_payment_json = {
            "payer_id": payerid,
            "transactions": [{
                "amount": {
                    "currency": "JPY",
                    "total": "100"
                }
            }]
        };

        return new Promise((resolve, reject) =>{
            paypal.payment.execute(paymentid, execute_payment_json, (error, payment) => {
                if (error) {
                    console.log(error.response);
                    return reject(error);
                }
                console.log(payment);

                for( var i = 0 ; i < payment.transactions.length ; i++ ){
                    for( var j = 0 ; j < payment.transactions[i].related_resources.length ; j++ ){
                        if( payment.transactions[i].related_resources[j].authorization ){
                            var authorization_id = payment.transactions[i].related_resources[j].authorization.id;
                            console.log('authorization_id=' + authorization_id);
                        }
                    }
                }

                var params = new URLSearchParams();
                for( var key in qs )
                    params.set(key, qs[key] );

                return resolve( new Redirect(base_url + '/paypal/index.html?' + params.toString() ));
            });
        });
    }
};

function do_get_token(url, qs, token){
    var params = new URLSearchParams();
    for( var key in qs )
        params.set(key, qs[key] );

    return fetch(url + '?' + params.toString(), {
        method : 'GET',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization' : 'Bearer ' + token }
    })
    .then((response) => {
        return response.json();
    });
}

function do_post_form_basic(url, qs, client_id, client_secret){
    var params = new URLSearchParams();
    for( var key in qs )
        params.set(key, qs[key] );

    const headers = { 
        "Content-Type" : "application/x-www-form-urlencoded",
        "Authorization" : "Basic " + new Buffer(client_id + ':' + client_secret).toString('base64')
    };

    return fetch(url, {
        method : 'POST',
        body : params,
        headers: headers
    })
    .then((response) => {
        return response.json();
    });
}
```

ユーティリティも示しておきます。

```:redirect.js
class Redirect{
    constructor(url){
        this.statusCode = 302;
        this.headers = {'Location' : url};
        this.body = null;
    }
}

module.exports = Redirect;
```

```:response.js
class Response{
    constructor(context){
        this.statusCode = 200;
        this.headers = {'Access-Control-Allow-Origin' : '*'};
        if( context )
            this.set_body(context);
        else
            this.body = "";
    }

    set_error(error){
        this.body = JSON.stringify({"err": error});
        return this;
    }

    set_body(content){
        this.body = JSON.stringify(content);        
        return this;
    }
    
    get_body(){
        return JSON.parse(this.body);
    }
}

module.exports = Response;
```

環境に合わせて、以下を変更してください。

【REST API appsのClient ID】
【REST API appsのSecret】
【RESTfulサーバを立ち上げているURL】

また、

　"return_url": base_url + "/paypal-redirect"

の部分で、ユーザによる同意の結果のリダイレクト先を指定しています。
また、以下の部分で、決済完了後のリダイレクト先を指定しています。

　return resolve( new Redirect(base_url + '/paypal/index.html?' + params.toString() ));

クライアント側のHTMLページも示します。

```:/paypal/index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
  <meta name="format-detection" content="telephone=no">
  <meta name="msapplication-tap-highlight" content="no">
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">

  <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>

  <title>PayPal 連携 テスト</title>

  <script src="js/vue_utils.js"></script>
  <script src="https://unpkg.com/vue"></script>
</head>
<body>
    <div id="top" class="container">
        <h1>PayPal 連携 テスト</h1>
        <br>
        <a class="btn btn-primary" href="【RESTfulサーバを立ち上げたURL】/paypal-create">Payment</a>
        <br>
    </div>

    <script src="js/start.js"></script>
</body>
```

```:start.js
'use strict';

const base_url = 【RESTfulサーバを立ち上げたURL】;
const PAYPAL_CLIENT_ID = 【REST API appsのClient ID】;

var vue_options = {
    el: "#top",
    data: {
    },
    computed: {
    },
    methods: {
    },
    created: function(){
    },
    mounted: function(){
        proc_load();

        if( searchs.paymentId ){
            history.replaceState(null, null, '.');
            alert('決済が完了しました。');
        }
    }
};
var vue = new Vue( vue_options );
```

```:vue_utils.js
var hashs = {};
var searchs = {};

function proc_load() {
  hashs = parse_url_vars(location.hash);
  searchs = parse_url_vars(location.search);
}

function parse_url_vars(param){
  if( param.length < 1 )
      return {};

  var hash = param;
  if( hash.slice(0, 1) == '#' || hash.slice(0, 1) == '?' )
      hash = hash.slice(1);
  var hashs  = hash.split('&');
  var vars = {};
  for( var i = 0 ; i < hashs.length ; i++ ){
      var array = hashs[i].split('=');
      vars[array[0]] = array[1];
  }

  return vars;
}

function vue_add_methods(options, funcs){
    for(var func in funcs){
        options.methods[func] = funcs[func];
    }
}
function vue_add_computed(options, funcs){
    for(var func in funcs){
        options.computed[func] = funcs[func];
    }
}
```

以下に示すように、決済が完了して元のページに戻ってくると、paymentIdが指定されてくるので、それを受けてダイアログ表示するようにしています。

        if( searchs.paymentId ){
            history.replaceState(null, null, '.');
            alert('決済が完了しました。');
        }



それではRESTfulサーバを立ち上げてみましょう。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/54e26654-52b6-2b89-5fde-7f6ca3e32b6f.png)
 
Paymentボタンを押下します。
そうすると、ログイン画面が表示されます。
Personalアカウントでログインします。自動で作られるXXXXX-buyer@YYYYY.yyy がありました。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/1a6cb0e8-990e-24f5-9afc-645c93ed96f9.png)

今度は、品目と価格の確認画面に変わります。
内容を確認したら「同意して続行」ボタンを押下します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/9bc50199-b0e7-4c75-40bf-87d7cd1d4562.png)

めでたく最初のページに戻ってきて、ダイアログが表示できました。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/5aa38a69-9a66-c5b6-8267-438861abc264.png)

# PayPalアカウントでのログイン機能の追加

ついでに、PayPalアカウントでのログイン機能を追加してみます。
クライアント側に少しコードを追加します。

```:/paypal/index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
  <meta name="format-detection" content="telephone=no">
  <meta name="msapplication-tap-highlight" content="no">
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">

  <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>

  <title>PayPal 連携 テスト</title>

  <script src="js/vue_utils.js"></script>
  <script src="https://unpkg.com/vue"></script>
</head>
<body>
    <div id="top" class="container">
        <h1>PayPal 連携 テスト</h1>
        <br>
        <span id='cwppButton'></span>
        <br>
        <br>
        <a class="btn btn-primary" href="【RESTfulサーバを立ち上げたURL】paypal-create">Payment</a>
        <br>
    </div>

    <script src="js/start.js"></script>
    <script src='https://www.paypalobjects.com/js/external/connect/api.js'></script>
    <script>
        paypal.use( ['login'], function (login) {
            login.render ({
            "appid": PAYPAL_CLIENT_ID,
            "authend":"sandbox",
            "scopes":"openid profile",
            "containerid":"cwppButton",
            "locale":"ja-jp",
            "buttonType":"CWP",
            "buttonSize":"lg",
            "returnurl": base_url + "/paypal/"
            });
        });
    </script>
</body>
```

以下の部分が、PayPalアカウントでのログインボタンになります。

```
<span id='cwppButton'></span>
```

各種設定は、以下の部分で行います。

　paypal.use( ['login'], function (login) 

containeridの指定の部分で指定したIDのspanの部分に、ボタンを描画してくれます。
ログインのために、PayPalが提供する画面に遷移したのち、ログイン完了後にまた戻ってきます。戻ってくる先は、「returnurl」の部分に指定します。
scopesには、取得したいユーザ情報の種類に応じて指定します。(後述)

スクリプトの本体は、以下です。

```
　<script src='https://www.paypalobjects.com/js/external/connect/api.js'>
```

PayPalからログイン完了後に戻ってくると、codeが指定されています。
これは認可コードであって、これをアクセストークンに変換する必要があります。
そのために、いったんRESTfulサーバに処理を移す必要があり、それが、以下の部分です。

        if( searchs.code ){

エンドポイント「/paypal-token」を呼んでいます。
呼び出しが成功すると、PayPalアカウント名が書かれたダイアログを表示するようにしています。

```:start.js
'use strict';

const base_url = 【RESTfulサーバを立ち上げたURL】;
const PAYPAL_CLIENT_ID = 【REST API appsのClient ID】;

var vue_options = {
    el: "#top",
    data: {
    },
    computed: {
    },
    methods: {
    },
    created: function(){
    },
    mounted: function(){
        proc_load();

        if( searchs.paymentId ){
            history.replaceState(null, null, '.');
            alert('決済が完了しました。');
        }
        if( searchs.code ){
            history.replaceState(null, null, '.');

            do_post(base_url + '/paypal-token', { code: searchs.code} )
            .then(json =>{
                console.log(json);
                alert(json.name + 'さん、こんにちは');
            });
        }
    }
};
var vue = new Vue( vue_options );

function do_post(url, body){
    console.log('do_post: ' + url);

    const headers = new Headers( { "Content-Type" : "application/json; charset=utf-8" } );
    
    return fetch(url, {
        method : 'POST',
        body : JSON.stringify(body),
        headers: headers
    })
    .then((response) => {
        return response.json();
    });
}
```

エンドポイント、/paypal-tokenのサーバ部分は、すでに実装を示してました。
世の中で一般的なOAuth2やOpenID Connectとほぼ同じなので、なじみがあるかと思います。

```:swagger.yaml
  /paypal-token:
    post:
      x-swagger-router-controller: routing
      operationId: paypal-token
      parameters:
        - in: body
          name: body
          schema:
            type: object
      responses:
        200:
          description: Success
          schema:
            type: object
```


それでは、もう一度、RESTfulサーバを再起動して、ブラウザからアクセスしてみます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/b1235f64-fd30-6212-74a9-dbcdebb407f5.png)
 
PayPalのボタンが増えているのがわかると思います。
PERSONALのアカウントでログインします。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/09551c04-88e1-22ca-24e1-b96479aa3531.png)

PayPalのログインアカウント名が書かれたダイアログが表示されたかと思います。成功です。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/0ba2f69d-71ad-965c-a6f3-0798c4be8215.png)


以下の呼び出しにより、アクセストークン等を取得しています。

　https://api.sandbox.paypal.com/v1/oauth2/token

```
{
  "token_type": "Bearer",
  "expires_in": "28800",
  "nonce": Nonce-Value,
  "scope": "openid profile",
  "refresh_token": Refresh-Token-Value,
  "access_token": Access-Token-Value
}
```

取得したトークンを使った例として、以下を呼び出してユーザ情報を取得しています。

　https://api.sandbox.paypal.com/v1/identity/oauth2/userinfo

```
{
 "user_id": "https://www.paypal.com/webapps/auth/identity/user/XXXXXXXX",
 "name": "buyer test"
}
```

scopesとして、openidとprofileしか指定しなかったため、上記情報だけですが、他も指定すると取得できる情報が増えます。
以下が参考になります。

　https://developer.paypal.com/docs/integration/direct/identity/attributes/


# 感想

PayPalのドキュメント類は、すごく見にくかったのですが、REST APIに絞って熟読することで、なんとなく動作させることができました。
今後は、これを足掛かりに、他の機能も見ていこうと思います。

以上