akiyoko blog

akiyoko の IT技術系ブログです

PayPal 決済の最新事情 〜 Django と PayPal と私 〜

この投稿は 「Django Advent Calendar 2016 - Qiita」 の 22日目の記事です。



この記事では、「Django と PayPal REST API で In-Context Window による PayPal 決済フロー」を実装・検証します。

はじめに謝罪しておきますが、「Django Advent Calendar」にも関わらず、結果として Django はオマケ程度にしか扱っていません。。


 

はじめに

オンライン決済の仕組み

ECサイトの決済処理の仕組みを簡単に図解すると、以下のようになります。

f:id:akiyoko:20160515172525p:plain
10 Safe and Popular Gateways for Online Payment Processing | InstantShift を参考に作成)


決済業者と直接契約するのはいろいろと面倒なので、代わりに決済代行業者と契約することで、Visa や Master Card などの各種クレジットカード(決済代行サービスによっては銀行振込やコンビニ決済も)が ECサイトで利用できるようになります。



少し細かい話になりますが、決済代行サービスごとに、決済処理システムとのやり取りの方式が異なります。

決済処理システムとの接続方式には、大きく分けて次の二種類があります。

接続方式 説明
リンク(画面遷移)方式 決済代行サービスのサイトに一旦遷移してクレジットカード情報(あるいは決済代行サービスのアカウント)などを入力させる方式
直接決済方式 画面遷移をおこなわず、バックエンドで直接決済処理をおこなう方式。実現方式や実装方法によってさらに細分化される。 *1


現在のところ、PayPal の接続方式は「リンク方式」のみ となっています。PayPal には「Direct Credit Card Payments」という直接決済方式の決済サービスもあるのですが、残念ながら(利用できるのは UKのみで)日本では利用できません。 *2

なお、GMOペイメントゲートウェイゼウス などの決済代行業者ではそれぞれの方式の決済代行サービスが各種取り揃えられています。また、最近本番サービスインとなった話題の Stripe は「直接決済方式」となっています。


なぜ PayPal?

私が必要としている決済サービスとしては、

  • 海外からの決済が可能(JPY以外の通貨が扱える) *3

というのを最低条件としていました。

そのほか、

  • PayPal に慣れている
  • PayPal のビジネスアカウントを既に持っている

という理由から、PayPal を第一候補に考えています。


PayPal の ECサイト用オンライン決済にもいろいろと実装方式(使用する API の種類など)があるのですが、

  • Braintree v.zero がプロダクション利用できるのは 2017年以降(2018年?)
  • Classic API よりも REST API を使いたい
  • In-Context Window を使ったフローの方が離脱が少ない

という事情を勘案して、「In-Context Window による決済フローを PayPal REST API で実装」するのがベストな選択肢であるという結論に達しました。


 

In-Context Window とは?

今回検証した PayPal のオンライン決済パターンは、下図のようなものになります。

f:id:akiyoko:20161222132204p:plain
NVP/SOAP Integration - PayPal Developer の図を元に作成。緑枠:自サイト、青枠:PayPal サイト)

① ショッピングカート画面(「PayPal で支払う」ボタン)
    ↓
② (In-Context Window 内)ログイン画面(「ログイン」ボタン)
    ↓
③ (In-Context Window 内)支払承認画面(「支払いに同意」ボタン)
    ↓
④ 決済完了画面


In-Context Window は、過去記事 の「5. ポップアップウィンドウ型(In-Context Window)」(小さなポップアップを立ち上げてその内部で PayPal サイトを表示させる新しいタイプの画面遷移パターン)に該当する決済フローです。

これまでの Express Checkout と違って、全画面が PayPal 決済ページにリダイレクトされることなく、小さなポップアップが立ち上がってその中で PayPal 決済ページを表示するというのが最大の特徴です。


<過去記事>
akiyoko.hatenablog.jp



これが、PayPal の数あるオンライン決済パターンの中で一番シンプルで最も新しいパターンになるかと思います。


また PayPal の公式ページでも、

PayPal no longer recommends full-page redirects to PayPal for Express Checkout. Instead, Express Checkout only requires a payment ID generated by the REST Payments API, or a payment token generated by the legacy NVP/SOAP APIs, to initiate and finalize a payment.


https://developer.paypal.com/docs/integration/direct/express-checkout/integration-jsv4/other-integrations/

ということで、全画面をリダイレクトするような画面遷移は今後はオススメしないということなので、今後の PayPal 決済では In-Context Window 型のフローが増えてくると思われます。



In-Context Window 型の通常決済(Checkout)を利用するには、フロント側に「checkout.js」という PayPal 謹製の JSライブラリを読み込ませて(*4)、あとは決まったやり方に則って実装するだけで OK です。

checkout.js は少し前まで V 3.5.0 だったのですが、現在の最新バージョンは V.4.0.0 となって、実装方法が若干変更されています。 *5


今回、V 4.0.0 で検証する前に V 3.5.0 でも実装・検証してみたのですが、より簡単に、よりセキュアに実装できるようになったという印象です。

具体的には、V 4.0.0 になって、

  • PayPal ボタン生成のときに、PAYPAL_CLIENT_ID を画面に晒さなくてよくなった
  • Create Payment のときに、生成した Payment から「redirect_url」を取り出してリダイレクトしなくてよくなった *6

などのうれしい変更点がありました。


 

PayPal REST API とは?

PayPal REST API は、PayPal が提供する様々な決済サービスを利用することができる RESTful API です。現時点で、PayPal が実現できるほぼ全ての決済サービスを網羅しているようです。 *7


なお、PayPal REST API は OAuth 2.0 プロトコルによる認可システムを採用しており、PayPal Developer サイトで作成した売り手アカウントの Credential(Client ID および Secret)を使用して各 API 呼び出しに必要なトークンを払い出します。 *8


Credential の作成方法については、ここでは説明を省略します。 *9



先に述べたように、PayPal REST API には様々な API の種類がありますが、オンライン決済処理ではその中から「Payments API」を使用すれば事足りるでしょう。 *10


PayPal REST API を便利に利用するためのライブラリとして、Python であれば PayPal Python SDK が 本家 PayPal から提供されていますので(Python のほかにも Java, PHP, .NET, Ruby, Node.js など各種言語向けの SDK が揃っています)、 pip でインストールして使います。


 

Django パッケージは使わないの?

Django Packages : Payment ProcessingDjango Packages : django SHOP plugins などで、PayPal に対応している決済パッケージをチェックしてみたのですが、

  • Django 1.10
  • PayPal REST API
  • In-Context Window(checkout.js V 4.0.0)

に対応しているものは今のところ見当たりません。


スターの多い順に確認してみると、django-merchant は「PayPal Website Payments Pro」のみ対応ということで日本では利用不可、django-lfs は「PayPal Payments Standard」のみ対応ということで API が古くて NG、django-paypal も「PayPal Payments Standard」または「PayPal Website Payments Pro」のみ対応ということで先の二つと同じでした。


そもそも「PayPal REST API で In-Context Window 決済」は自前で実装してもそんなに大変ではなくて、Django パッケージをわざわざ使うまでもないといった印象です。




ということで、前置きがずいぶん長くなってしまいましたが、「Django と PayPal REST API で In-Context Window による PayPal 決済フロー」を実際に試していきます。



実装前には、以下のドキュメントをざっと読んでおくことをお勧めします。


 

実装

購入する商品と合計金額が表示されたカート画面から PayPal 決済をおこなうことを想定した、「shop」アプリケーションを作ってみます。

/opt/webapps/myproject/
├── config
│   ├── __init__.py
│   ├── local_settings.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── manage.py
├── shop
│   ├── apps.py
│   ├── __init__.py
│   ├── urls.py
│   └── views.py
└── templates
    ├── base.html
    ├── error.html
    └── shop
        ├── base_shop.html
        ├── cart.html
        └── complete.html


動作確認をおこなった Python および Django のバージョンは以下の通りです。

  • Python 2.7.6
  • Django 1.10



ソースコードは GitHub に置きました。
github.com



 

Settings

conf/settings.py (抜粋)

# Application definition

INSTALLED_APPS = [
    ...
    'shop',
]
...
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        ...
        },
    },
]
...
# LOCAL SETTINGS
PROJECT_APP_PATH = os.path.dirname(os.path.abspath(__file__))
PROJECT_APP = os.path.basename(PROJECT_APP_PATH)
f = os.path.join(PROJECT_APP_PATH, 'local_settings.py')
if os.path.exists(f):
    import sys
    import imp
    module_name = '%s.local_settings' % PROJECT_APP
    module = imp.new_module(module_name)
    module.__file__ = f
    sys.modules[module_name] = module
    exec (open(f, 'rb').read())


conf/local_settings.py (抜粋)

DEBUG = True

# PayPal
PAYPAL_MODE = '<paypal-mode>'  # 'sandbox' or 'live'
PAYPAL_CLIENT_ID = '<paypal-client-id>'
PAYPAL_CLIENT_SECRET = '<paypal-client-secret>'

 

URLConfs

shop/urls.py

from django.conf.urls import url

from . import views

urlpatterns = [
    url(r'^cart$', views.ShowCartView.as_view(), name='cart'),
    url(r'^create-payment$', views.CreatePaymentView.as_view(), name='create-payment'),
    url(r'^execute-payment$', views.ExecutePaymentView.as_view(), name='execute-payment'),
]

 

Views

shop/views.py

import logging

from django.conf import settings
from django.contrib import messages
from django.http import JsonResponse, Http404
from django.shortcuts import render, reverse
from django.views.generic import View
import paypalrestsdk

logger = logging.getLogger(__name__)


class ShowCartView(View):
    def get(self, request, *args, **kwargs):
        return render(request, 'shop/cart.html', {
            'paypal_mode': settings.PAYPAL_MODE,
        })


class CreatePaymentView(View):
    def post(self, request, *args, **kwargs):
        paypalrestsdk.configure({
            'mode': settings.PAYPAL_MODE,
            'client_id': settings.PAYPAL_CLIENT_ID,
            'client_secret': settings.PAYPAL_CLIENT_SECRET,
        })

        payment = paypalrestsdk.Payment({
            'intent': 'sale',

            # Payer
            'payer': {
                'payment_method': 'paypal',
            },

            # Redirect URLs
            'redirect_urls': {
                'return_url': request.build_absolute_uri(reverse('shop:execute-payment')),
                'cancel_url': request.build_absolute_uri(reverse('shop:cart')),
            },

            # Transaction
            # Note: This is dummy. If production, transaction should be created with reference to cart items.
            'transactions': [{
                # Item List
                'item_list': {
                    'items': [{
                        'name': 'item',
                        'sku': 'item',
                        'price': '5.00',
                        'currency': 'USD',
                        'quantity': 1,
                    }]
                },
                # Amount
                'amount': {
                    'total': '5.00',
                    'currency': 'USD',
                },
                'description': 'This is the payment transaction description.',
            }]
        })

        # Create Payment
        if payment.create():
            logger.info("Payment[{}] created successfully.".format(payment.id))
            return JsonResponse({'success': True, 'paymentId': payment.id})
        else:
            logger.error("Payment failed to create. {}".format(payment.error))
            return JsonResponse({'success': False, 'error': "Error occurred while creating your payment."}, status=500)


class ExecutePaymentView(View):
    def get(self, request, *args, **kwargs):
        # Query strings are always in request.GET
        payment_id = request.GET.get('paymentId', None)
        payer_id = request.GET.get('PayerID', None)

        try:
            payment = paypalrestsdk.Payment.find(payment_id)
        except paypalrestsdk.ResourceNotFound as err:
            logger.error("Payment[{}] was not found.".format(payment_id))
            return Http404

        # Execute Payment
        if payment.execute({'payer_id': payer_id}):
            logger.info("Payment[{}] executed successfully.".format(payment.id))
            messages.info(request, "Your payment has been completed successfully.")
            return render(request, 'shop/complete.html', {
                'payment': payment,
            })
        else:
            logger.error("Payment[{}] failed to execute.".format(payment.id))
            messages.error(request, "Error occurred while executing your payment.")
            return render(request, 'error.html')

Create Payment は
PayPal-Python-SDK/create_with_paypal.py at master · paypal/PayPal-Python-SDK · GitHub
を参考に、Execute Payment は
PayPal-Python-SDK/execute.py at master · paypal/PayPal-Python-SDK · GitHub
を参考にしました。


なお、例外処理は全然ケアしていないので、本番で使う場合には要注意です。


 

Templates

shop/cart.html

{% extends "./base_shop.html" %}

{% block title %}Cart{% endblock title %}

{% block content %}
{{ block.super }}
<div id="paypal-button"></div>

<script src="https://www.paypalobjects.com/api/checkout.js" data-version-4></script>
<script>
    paypal.Button.render({
        env: '{{ paypal_mode }}',
        payment: function (resolve, reject) {
            paypal.request.post('{% url "shop:create-payment" %}', {csrfmiddlewaretoken: '{{ csrf_token }}'})
                .then(function (data) {
                    console.log("data=", data);
                    if (data.success) {
                        resolve(data.paymentId);
                    } else {
                        reject(data.error);
                    }
                })
                .catch(function (err) {
                    console.log("err=", err);
                    reject(err);
                });
        },
        onAuthorize: function (data, actions) {
            return actions.redirect();
        },
        onCancel: function (data, actions) {
            return actions.redirect();
        },
        onError: function (err) {
            // Show an error page here, when an error occurs
        }
    }, '#paypal-button');
</script>
{% endblock content %}

フロントのスクリプトは、

などを参考にしていただければ。


また、POST送信時に「csrfmiddlewaretoken」というパラメータを付与しないと 403エラーになるのは、Django に詳しい皆さんにはお馴染みですよね。



shop/complete.html

{% extends "./base_shop.html" %}

{% block title %}Complete{% endblock title %}

{% block content %}
<span style="font-size: 0.7rem;">{{ payment }}</span>
{% endblock content %}

 

処理概要

ここで、In-Context Window の処理フローを少し解説すると以下のようになります。

  1. paypal.Button.render で PayPal ボタンを表示
  2. ユーザがボタンをクリックすると、paypal.requet.post() で '/shop/create-payment' にリクエストを POST する
  3. サーバ側で商品情報や合計金額、およびPayPal からリダイレクトさせる URL を含めた Payment を create して、payment.id を JSON で返す
  4. checkout.js が勝手に PayPalサイトにリダイレクトしてくれる
  5. In-Conetxt Window 内でユーザが決済を承認して同意すると、onAuthorize、キャンセルすると onCancel がコールバックされるので、それぞれのコールバック関数の中で適宜リダイレクトをする(ここでは勝手にリダイレクトされない)
  6. 「/shop/execute-payment?paymentId=xxx&token=yyy&PayerID=zzz」という URL でリクエストされるので、サーバ側で paymentId, PayerID を取得して Payment を execute して、決済完了画面を表示させる


実装を見ながら、処理の流れをチェックすると分かりやすいかと思います。


 

動作確認

最後に、実際に検証環境で画面を動かしながら、動作を確認してみます。


ブラウザで「http://localhost:8000/shop/cart」にアクセスし、PayPal のチェックアウトボタンをクリックします。
f:id:akiyoko:20161221135159p:plain

In-Context Window(モーダルウィンドウみたいなもの)が起動して、PayPal サイト(サンドボックス)のログイン画面が表示されます。
PayPal の買い手アカウント情報を入力してログインし、
f:id:akiyoko:20161221145000p:plain

「同意して続行」をクリックします。
f:id:akiyoko:20161221145025p:plain

onAuthorize がコールバックされ、return_url の「/shop/execute-payment」に redirect() され、shop/complete.html に payment オブジェクトの中身がダンプされました。
f:id:akiyoko:20161221145046p:plain



 

まとめ

Django で PayPal 決済を検討しているのであれば、今回紹介したように、フロントは「In-Context Window の checkout.js V 4.0.0」、バックエンドは「PayPal REST API 」という現時点での最新スタイルがオススメです。

ドキュメントや記事がまだ少ないのが欠点ですが、PayPal の日本チームもそこには今後力を入れていくそうなので期待しましょう。

PayPal の決済フローもドキュメントも、現在進行形でどんどん進化していますよ!!


最後にひと言。

PayPal REST API で In-Context Window 決済はいいぞ!


ご拝読ありがとうございました。「Django と Paypal と私」でした。




明日は、luizs81 さんの 23日目の記事です。よろしくお願いします。


オススメ Django 本

Django のベストプラクティス本です。
全編英語ですが絶対オススメです。

Two Scoops of Django: Best Practices for Django 1.8

Two Scoops of Django: Best Practices for Django 1.8


記事を書きましたので、是非ご参考に。


<過去記事>
akiyoko.hatenablog.jp

*1:決済代行業者によって呼び方も異なる。例えば、GMOペイメントゲートウェイ では「プロトコルタイプ」「モジュールタイプ」、ゼウス では「トークン(JavaScript)型」「データ伝送(API)型」などと呼ばれるが、いずれも画面遷移を伴わない直接決済方式である。

*2:https://developer.paypal.com/docs/classic/api/#website-payments-pro

*3:2017年4月末でのサービス終了が宣言された「WebPay」では、海外向けでの販売のみを前提とした利用はできないと規定されていました。 https://webpay.jp/faq#constraints

*4:常に最新版のものを利用するために、PayPal の CDN を利用することが推奨されています。

*5:V 3.5.0 から V 4.0.0 への移行方法については、「Upgrade checkout.js to V4.0.0 - PayPal Developer」を参照

*6:payment_id を JSON形式で返すだけで、後は checkout.js が勝手にリダイレクトしてくれるようになりました。

*7:利用できる API の種類については「REST API reference - PayPal Developer」を参照

*8:詳しい仕組みについては「How PayPal uses OAuth 2.0 - PayPal Developer」を参照

*9:https://www.paypal-knowledge.com/infocenter/index?page=content&id=FAQ1949 が新しくて参考になりそうです。

*10:本番では「Payment Experience API」を組み合わせて使うこともありますが今回は利用しません。