株式会社ネクステージが開発・運用を行っている演劇パスというサービスではPayPal IPNを利用して決済を行っていましたが、この度セキュリティ強化と、タイムアウトの実装のため、PayPal REST API(ExpressCheckout)に切り替えを行いました。
開発の際の注意点などを含め導入方法について、解説していきたいと思います。
今回は、タイムアウトの処理を足したいというだけなので、PayPal公式や他サイトで紹介している、自サイトに戻ってきた後、ユーザーによる決済の確認を行うという動作は省略し、Paypal側がOKであれば自動的に決済を完了する形で実装しました。
環境
- PHP 7.0
- CakePHP 2.2.5
使用したプラグイン
Paypal Plugin for CakePHP 2.x https://github.com/robmcvey/cakephp-paypal
IPNとREST APIの違い
最初に結論を言うと、今回の実装ではユーザーからの見た目はほとんど変わりません。
PayPalにデーターを送信する方法と決済が完了するタイミングが変わるだけです。
PayPalの決済手段と違いについては以下のサイトが綺麗にまとまっているので参照してみてください。
http://akiyoko.hatenablog.jp/entry/2016/08/22/082539
使い方
1.プラグインのインストール
https://github.com/robmcvey/cakephp-paypal からプラグインをダウンロードして
app/Plugin/Paypal/にコピー
bootstrap.php に以下を追記
CakePlugin::load('Paypal');
使用する前もしくは、AppModel、AppControllerなどに以下を追記
$paypal_params = [
'sandboxMode' => false, //falseだと本番(Live)、trueだとテスト(sandbox)
'nvpUsername' => 'username', //ユーザーネーム
'nvpPassword' => 'password', //APIのパスワード
'nvpSignature' => 'signature' //APIのシグネーチャー
];
App::uses('Paypal', 'Paypal.Lib');
$this->Paypal = new Paypal($paypal_parms);
これで準備完了
2.オーダーを送信
PayPalに移動する前にPayPalに決済する情報を送信します。
$items[] = [
'name' => '商品名',
'description' => '商品詳細',
'subtotal' => '値段',
'tax' => '税金(外税)',
'shipping' => '商品別送料',
'qty' => 1//個数
];
$order = [
'description' => '決済名',
'currency' => 'JPY',//日本円
'return' => '決済処理を行うURL',
'cancel' => '決済を中断した時戻るURL',
'custom' => 'カスタムMessage',
'items' => $items,
'shipping' => '送料'
];
$this->Session->write('order',$order);
try {
$setExpressCheckout = $this->Paypal->setExpressCheckout($order);//PayPalにリダイレクトするためのURLが返ってくる
$this->redirect($setExpressCheckout); //PayPalに進む
} catch (Exception $e) {
//debug($e->getMessage());
}
注意点
$items
は、複数のアイテムが指定できる。
taxは外税で計算されて、PayPalで表示される時は内税になるので、紛らわしい。
subtotalはトータルと書いてあるけど、単価
請求金額=(subtotal+tax+shipping)×qtyなので注意
tax、shippingの指定は任意
$order
のcustomには、任意の文字列を入れて、決済完了用ページにPayPalからリダイレクトされる際に渡されるもののはずだけど、返ってこない。
customと、shippingは任意
決済を完了する時に完全に一致する$order
を送信する必要があるので、今回はsessionに書き込んでおく。
3.決済情報を受け取り決済を完了
PayPal側でユーザーの認証と、準備が完了したら、setExpressCheckoutの$order['return']
で指定したURLに
トークンとPlayerIDと一緒にリダイレクトされる。
http://example.com/callbackpaypal/?token=EC-44V10330880299912&PayerID=FCT3VE4Y4Q18V
こんな感じのGETパラメーターが付加されてリダイレクトしてくるので、決済を完了させる。
//決済に必要なデータの所得
$token = $this->request->query('token');
$payerId = $this->request->query('PayerID');
$order = $this->Session->read('order');
$this->Session->delete('order');
if (empty($token) || empty($payerId || empty($order))) {
//必要なデータが不足している
return;
}
try {
//決済情報が有効かどうかPayPalに問い合わせる
$getExpressCheckoutDetails = $this->Paypal->getExpressCheckoutDetails($token);
if ($getExpressCheckoutDetails['PAYMENTREQUESTINFO_0_ERRORCODE'] != 0) {
//決済を続けられない
throw new Exception("getExpressCheckoutDetailsError", $getExpressCheckoutDetails['PAYMENTREQUESTINFO_0_ERRORCODE']);
}
# ここにDB書き込みなどサイト内部の処理を書く
//決済を確定する
$doExpressCheckoutPayment = $this->Paypal->doExpressCheckoutPayment($order, $token, $payerId);
if (!$doExpressCheckoutPayment) {
//決済に失敗
throw new Exception("doExpressCheckoutPayment",1);
}
} catch (Exception $e) {
//debug($e->getMessage());
}
注意点
$getExpressCheckoutDetails['PAYMENTREQUESTINFO_0_ERRORCODE']
に0以外の数字が入っている場合何らかの理由で決済ができないトークンであることになる。トークンの有効期限切れや、すでに決済が行われたトークン、トークンが見つからない時などがこの場合に当たる。
getExpressCheckoutDetails
で決済できないトークンの場合でも、APIの処理自体はsuccessが返ってくるため、例外は発生しないので注意。通信エラーなどはちゃんと例外になる。
doExpressCheckoutPayment
で決済ができなかった場合、使用してみた感じでは必ず例外が発生するので、直下のifまで処理されず全て例外になる。
この場合、例外は PaypalExceptionか、PaypalRedirectExceptionが発生する。
まとめ
以上でとりあえず、CakePHPでのPayPalプラグインを使用したREST APIのテストはできるはずです。
最初に述べた通り今回は、callbackされてきたページでの決済の確認をユーザーに行わず、Paypal側がOKであれば自動的に決済を完了する形で実装しました。
もちろん、お好みで、ユーザーへの確認画面などを挟むこともできます。
実際の決済にはDBへの書き込みや、決済に失敗した時の処理などが必要ですが、参考になれば幸いです。
では
資料・付録
実装の際にLogを取るためにREST APIの戻り値を調べたので、付録として参考までに一覧をつけておきたいと思います。
調査を行ったのは2016年12月です。実際に返ってきたパラメーターをなるべく写していますが、トークンやIDなどは、加工しています。
Paypalの仕様が変わることもあるので、こんなことが受け取れるんだと参考にしてください。
一覧を見るとわかると思いますが、customの変数は返ってきません。(なぜかわからない)
今回使用したプラグインでは機能がないものが多く、決め打ちの値が返ってくるのもかなりあります。(商品のサイズとかは全て0)
getExpressCheckoutDetailsの戻り値
パラメータ名 | 例 | 説明 | 定数一覧 |
---|---|---|---|
TOKEN | EC-2096785279340000W | 決済トークン | |
BILLINGAGREEMENTACCEPTEDSTATUS | 0 | 定期支払の受け入れ | 0(受け入れていない) 1(受け入れた) |
CHECKOUTSTATUS | PaymentActionNotInitiated | 決済の状況 | PaymentActionNotInitiated (支払いが開始されていない) PaymentActionFailed (決済失敗) PaymentActionInProgress (決済処理中) PaymentActionCompleted (決済完了済み) |
TIMESTAMP | 2016-12-14T03:48:54Z | タイムスタンプ | |
CORRELATIONID | 39fb8b76ad8nn | レスポンスID | |
ACK | Success | リクエストのステータス | |
VERSION | 104 | APIバージョン | |
BUILD | 0 | サブバージョン | |
sample@example.com | 買い手のアドレス | ||
PAYERID | XXXXXXXXXXXXX | 買い手のユーザーID | |
PAYERSTATUS | verified | ユーザーのステータス | verified unverified |
FIRSTNAME | Test | ユーザーの名前 | |
LASTNAME | User | 苗字 | |
COUNTRYCODE | JP | ユーザーの所属国 | |
CURRENCYCODE | JPY | 支払い通貨 | |
AMT | 1200 | 合計 | |
ITEMAMT | 1200 | 商品価格の合計 | |
SHIPPINGAMT | 0 | 送料合計 | |
HANDLINGAMT | 0 | 梱包費合計 | |
TAXAMT | 0 | 税金合計 | |
DESC | 決済名 | 決済の詳細 | |
INSURANCEAMT | 0 | 出荷保険 | |
SHIPDISCAMT | 0 | 送料の割引 | |
L_NAME0 | 商品名 | 商品名 | |
L_QTY0 | 1 | 商品数 | |
L_TAXAMT0 | 0 | 消費税(外税) | |
L_AMT0 | 1200 | 商品の値段 | |
L_DESC0 | 商品の詳細 | 商品名の詳細 | |
L_ITEMWEIGHTVALUE0 | 0 | 商品の重さ(単位無) | |
L_ITEMLENGTHVALUE0 | 0 | 商品の長さ(単位無) | |
L_ITEMWIDTHVALUE0 | 0 | 商品の幅(単位無) | |
L_ITEMHEIGHTVALUE0 | 0 | 商品の高さ(単位無) | |
L_ITEMCATEGORY0 | Physical | デジタルコンテンツかどうか | Digital Physical |
PAYMENTREQUEST_0_ CURRENCYCODE |
JPY | 通貨 | |
PAYMENTREQUEST_0_AMT | 1200 | 支払い合計 | |
PAYMENTREQUEST_0_ITEMAMT | 1200 | 購入金額の合計(送料等を除く) | |
PAYMENTREQUEST_0_SHIPPINGAMT | 0 | 送料合計 | |
PAYMENTREQUEST_0_HANDLINGAMT | 0 | 梱包費 | |
PAYMENTREQUEST_0_TAXAMT | 0 | 税金合計 | |
PAYMENTREQUEST_0_DESC | 決済の説明 | 支払いの説明 | |
PAYMENTREQUEST_0_ INSURANCEAMT |
0 | 出荷保険費用 | |
PAYMENTREQUEST_0_ SHIPDISCAMT |
0 | 送料の割引料金 | |
PAYMENTREQUEST_0_ INSURANCEOPTIONOFFERED |
FALSE | 保険適用の有無 | |
PAYMENTREQUEST_0_ ADDRESSNORMALIZATIONSTATUS |
None | 住所正規化ステータス? | |
L_PAYMENTREQUEST_0_NAME0 | 商品名 | 商品名 | |
L_PAYMENTREQUEST_0_QTY0 | 1 | 商品数 | |
L_PAYMENTREQUEST_0_TAXAMT0 | 0 | 消費税 | |
L_PAYMENTREQUEST_0_AMT0 | 1200 | 値段 | |
L_PAYMENTREQUEST_0_DESC0 | 商品の詳細 | 商品の詳細 | |
L_PAYMENTREQUEST_0_ ITEMWEIGHTVALUE0 |
0 | 商品の重さ(単位無) | |
L_PAYMENTREQUEST_0_ ITEMLENGTHVALUE0 |
0 | 長さ | |
L_PAYMENTREQUEST_0_ ITEMWIDTHVALUE0 |
0 | 幅 | |
L_PAYMENTREQUEST_0_ ITEMHEIGHTVALUE0 |
0 | 高さ | |
L_PAYMENTREQUEST_0_ ITEMCATEGORY0 |
Physical | デジタルコンテンツかどうか | Digital Physical |
PAYMENTREQUESTINFO_0_ ERRORCODE |
0 | エラーコード(0はエラーなし) |
doExpressCheckoutPaymentの戻り値
パラメータ名 | 例 | 説明 | 定数一覧 |
---|---|---|---|
TOKEN | EC-7GA30889WP510000A | 決済トークン | |
SUCCESSPAGEREDIRECTREQUESTED | FALSE | 取引完了後にPayPalにリダイレクトするか | |
TIMESTAMP | 2016-12-14T03:59:35Z | タイムスタンプ | |
CORRELATIONID | e39075c57fnn | レスポンスID | |
ACK | Success | レスポンスステータス | |
VERSION | 104 | AIPバージョン | |
BUILD | 0 | APIのサブバージョン | |
INSURANCEOPTIONSELECTED | FALSE | 保険の使用 | |
SHIPPINGOPTIONISDEFAULT | FALSE | デフォルトの配送方法を選択したか | |
PAYMENTINFO_0_TRANSACTIONID | 9AH12342KL1211703 | トランザクションID APIでキャンセルなどをする際に必要 |
|
PAYMENTINFO_0_TRANSACTIONTYPE | cart | トランザクションタイプ | |
PAYMENTINFO_0_PAYMENTTYPE | instant | 決済のタイミング | none(支払いなし) echeck(後払い) instant(即時) |
PAYMENTINFO_0_ORDERTIME | 2016-12-14T03:59:34Z | 決済の時間 | |
PAYMENTINFO_0_AMT | 1200 | 決済された金額 | |
PAYMENTINFO_0_FEEAMT | 67 | 決済から引かれた手数料(内) | |
PAYMENTINFO_0_TAXAMT | 0 | 税金 | |
PAYMENTINFO_0_CURRENCYCODE | JPY | 通貨 | |
PAYMENTINFO_0_PAYMENTSTATUS | Completed | 決済のステータス | |
PAYMENTINFO_0_PENDINGREASON | None | 支払いが保留になった理由 | |
PAYMENTINFO_0_REASONCODE | None | トランザクションタイプが一致しない場合の説明 | |
PAYMENTINFO_0_PROTECTIONELIGIBILITY | Ineligible | 決済がPaypalの保護対象かどうか | |
PAYMENTINFO_0_PROTECTIONELIGIBILITYTYPE | None | 保護の種類 | |
PROTECTIONELIGIBILITY | 3GFRTLTJUAJA4 | 公式マニュアルでは PAYMENTINFO_n_PROTECTIONELIGIBILITYと同じ |
|
PAYMENTINFO_0_ERRORCODE | 0 | エラーコード なしの場合は0 | |
PAYMENTINFO_0_ACK | Success | 結果のステータス |