利用 PayPal 付款標準版 (PayPal Payment Standard) 以及 PayPal 即時付款通知 (PayPal IPN) 方式結帳付款

前言

有嘗試串接過 PayPal 的人都知道, PayPal 提供了好幾種方式供使用者串接使用。
本篇記錄了:

  1. 如何使用 PayPal 付款標準版 (PayPal Payment Standard) 來付款結帳。
  2. 如用使用 PayPal 即時付款通知 (PayPal IPN) 來驗證付款結果。
  3. 如何在 PayPal 付款標準版 (PayPal Payment Standard) 中,自定義多個商品以及每個商品的明細,包含名稱、單價、數量。

在本文章中,我使用的是PHP的框架,Laravel
因為此篇文章主要紀錄我這個專案大概的一個流程,雖說主題是金流部分,但難免會記錄到一些跟金流無關的部分。
可以直接從’建金流訂單’的部分開始看即可。

驗證

此節是整個流程的一個程序,跟金流較無關係,可以跳過。

$toBeValidatedCondition = [
'order_id' => 'required|array',
];
$failMessage = Helpers::validation($toBeValidatedCondition, $request);
if ($failMessage)
return Helpers::result(false, $failMessage, 400);

if (!Helpers::checkIfIDExists($request, new Order(), 'order_id'))
return Helpers::result(false, 'The orders doesn\'t exist', 400);

if (!Helpers::checkIfBelongToTheUser($request, new Order(), 'order_id'))
return Helpers::result(false, 'The order doesn\'t belong to this user', 400);


$orders = Order::whereIn('id', $request->order_id)->get();

if (Order::checkIfOrderPaid($orders))
return Helpers::result(false, 'The order has already been paid', 400);

if (Order::checkIfOrderExpired($orders))
return Helpers::result(false, 'The order has expired', 400);

if ($recipient->user_id !== User::getUserID($request))
return Helpers::result(false, 'The recipient doesn\'t belong to the user', 400);

收集必要資訊

因為我在做這個專案時,前端的時間比較吃緊一點,所以後端這邊決定除必要資訊之外,後端這邊將所有資訊搞定,盡量讓前端帶最少的資料,做最多的事。

$toBeSavedInfo = [
'total_amount' => Order::getTotalAmountForPayments($orders),
'orders_name' => Order::getOrdersNameForPayments($orders),
'merchant_trade_no' => time() . Helpers::createAUniqueNumber(),
'merchant_trade_date' => date('Y/m/d H:i:s'),
'trade_desc' => 'BuyBuyGo',
'quantity' => 1,
'user_id' => User::getUserID($request),
'payment_service' => $thirdPartyPaymentService,
'expiry_time' => (new Carbon())->now()->addDay(1)->toDateTimeString(),
'orders' => $orders,
'mc_currency' => 'TWD',
'ClientBackURL' => $request->ClientBackURL
];

分流點

因為這個專案接了兩家金流,所以會需要一個地方來判定金流服務商

switch ($thirdPartyPaymentService->id)
{
case 1:
$error = (new AllPay)->make($toBeSavedInfo, $request, $recipient);
if($error)
return Helpers::result(false, $error,400);

return (new AllPay())->send($toBeSavedInfo, $request);
break;

case 2:
$error = (new PayPal)->make($toBeSavedInfo, $request, $recipient);
if($error)
return Helpers::result(false, $error, 400);

$url = (new PayPal)->send($toBeSavedInfo, $request, $recipient);
return Helpers::result(true, $url, 200);
break;
}

建金流訂單

在使用者按下付款之後,一張臨時的金流訂單會被建立。此訂單只介於你與你與 PayPal 之間,使用者不會接觸到這張訂單。
因為會一次性的寫入兩張 table ,所以這邊會特別使用 Laravel 的 Transaction 來將資料處理,如果對 Laravel Transaction 有興趣的,可以參考我在另外一篇文章中,有一小段針對 Laravel Transaction 的解說

public function make(Array $toBeSavedInfo, Request $request, Recipient $recipient)
{
DB::beginTransaction();
try
{
$PayPal = new self();

$PayPal->user_id = $toBeSavedInfo['user_id'];
$PayPal->payment_service_id = $toBeSavedInfo['payment_service']->id;
$PayPal->expiry_time = $toBeSavedInfo['expiry_time'];
$PayPal->merchant_trade_no = $toBeSavedInfo['merchant_trade_no'];
$PayPal->total_amount = $toBeSavedInfo['total_amount'];
$PayPal->trade_desc = $toBeSavedInfo['trade_desc'];
$PayPal->item_name = $toBeSavedInfo['orders_name'];
$PayPal->mc_currency = $toBeSavedInfo['mc_currency'];
$PayPal->recipient_id = $recipient->id;
$PayPal->save();

foreach ($toBeSavedInfo['orders'] as $order)
{
$order_relations = new OrderRelations();
$order_relations->payment_service_id = $toBeSavedInfo['payment_service']->id;
$order_relations->payment_service_order_id = $PayPal->id;
$order_relations->order_id = $order->id;
$order_relations->save();
}
} catch (Exception $e)
{
DB::rollBack();

return 'something went wrong with DB';
}
DB::commit();
}

建立提交付款申請的 URL

這邊會用到很多 PayPal 付款標準版 (PayPal Payment Standard) 的 變量 (variable),各種變量的使用可以參考這篇文章
另外,因為 Ray 在做這個案子時,前端的時間上比較吃緊,所以 Ray 將所以非必要的資料全部由後端這邊處理,前端只帶入先前已建立的使用者訂單,後端從資料庫內調出所有的資料並提供給 PayPal

public function send(Array $toBeSavedInfo, Request $request, Recipient $recipient)
{
// 如果你是使用測試環境的話,請選 true
$enableSandbox = env('PAYPAL_SANDBOX_ENABLESANDBOX');

$paypalUrl = $enableSandbox ? 'https://www.sandbox.paypal.com/cgi-bin/webscr' : 'https://www.paypal.com/cgi-bin/webscr';

$data = [];

// 設定PayPal 帳號, 請先到以下網址申請測試者帳號,商家跟一般用戶都要申請,這邊填入的是商家的帳號,所以買家付款後,金額會直接匯入這個帳號
// https://developer.paypal.com/developer/accounts/
// Set the PayPal account
$data['business'] = env('PAYPAL_SANDBOX_MAIL');

// 此數值為前端帶入,使用者完成付款後,將可以經由此URL返回原本的服務中
// Set the PayPal return addresses, after the transaction is completed, the user could be back via this URL.
$data['return'] = $toBeSavedInfo['ClientBackURL'];

// 在付款過程中,使用者可以選擇取消,並且經由此URL回到我們的服務
// During the transaction process on PayPal's site, the user could cancel the transaction and go back via this URL.
$data['cancel_return'] = env('PAYPAL_SANDBOX_CANCEL_URL');

// 在使用者完成交易之後, PayPal 會發一封 IPN 到我們在這邊指定的 listener ,然後我們可以依據此 IPN 來判定付款是否成功,然後做相對應的事
// After the transaction is completed, PayPal will send IPN message to this URL.
$data['notify_url'] = env('PAYPAL_SANDBOX_NOFITY_URL');

// 這邊我們指定了每一樣商品的明細,包含單價,名稱,數量。 這邊要將這些明細顯示在 PayPal 的付款頁面上
// Set the details about the products being purchased, including the price for every individual
// and currency so that these aren't overridden by the form data.
$i = 1;
foreach ($toBeSavedInfo['orders'] as $order)
{
$data["item_name_$i"] = $order->item_name;
$data["item_number_$i"] = $order->quantity;
$data["amount_$i"] = $order->total_amount;
$i++;
}

// 這邊指定了幣別,細節部分可以參考官網資料
// https://developer.paypal.com/docs/classic/api/currency_codes/
$data['currency_code'] = $toBeSavedInfo['mc_currency'];

// 這邊我們可以帶一個任何我們想要的值過去給 PayPal ,然後 PayPal 會再回傳 IPN 時一併帶回來,以本篇例子,我帶入的是我金流訂單的編號
// Add any custom fields for the query string.
$data['custom'] = $toBeSavedInfo['merchant_trade_no'];

// 這邊我指定了收件人,否則 PayPal 會顯示測試帳號上的假的收件人資料。 在這邊設定後,我們可以在 PayPal 上顯示任何我們想要的收件人資料
// Add recipient's information
$data['address_override'] = 1;
$data['country'] = $recipient->country_code;
$data['city'] = $recipient->city;
$data['address1'] = $recipient->others;
$data['zip'] = $recipient->postcode;
$data['first_name'] = $recipient->name;

// 這邊的設定允許了我們上傳多個商品到 PayPal 的購物車裡面,所以可以分成多個商品並且一次性結帳。
// This setting allow to add multiple items with IPN method
$data['upload'] = '1';
$data['cmd'] = "_cart";

// Add charset
$data['charset'] = 'utf-8';

// 產生 query string
// Build the query string from the data.
$queryString = http_build_query($data);

// 產生最終的要在 PayPal 建立付款請求的 URL,我們只需要將此URL回傳給前端,前端就可以直接利用這個 URL 將使用者導向付款頁面
// Build the URL to PayPal
$url = $paypalUrl . '?' . $queryString;

return $url;
}

使用者付款

  1. 使用者經由我們上面產出的 URL 到達 PayPal 付款頁面,這邊請先去申請測試帳號

  1. 到達付款頁面

  2. 這邊可以看到我們指定的商品明細,以及金額,選擇繼續

  3. 這邊可以看到我們指定的住址

  4. 交易成功,這邊可以看到全部的細節

驗證付款狀態

使用者完成付款程序後, PayPal 會發一封 IPN 給我們,有關於 IPN 的規格可以參考官方文件

  1. 首先,我們先安裝 PayPal 的 官方 IPN CODE SAMPLES

    git clone https://github.com/paypal/ipn-code-samples
  2. 進到 php 的資料夾內

    cd ipn-code-samples/php
  3. 接下來,我們複製PaypalIPN.php到我們的專案內, Ray 是把它放到 App 底下。

  4. 接著,在把 ipn-code-samples 裡頭的 cert 整個資料夾也放到 App 底下,大概如下圖

  5. 再來,我們到composer.json檔案中,在autoload-dev底下的files加入PaypalIPN.php這個檔案,如果沒有files的可能要自己建一個

    "autoload-dev": {
    "psr-4": {
    "Tests\\": "tests/"
    },
    "files": [
    "app/Helpers.php",
    "app/AllPay.Payment.Integration.php",
    "app/PaypalIPN.php"
    ]
    },
  6. 重新執行composer,在terminal的專案資料夾底下,執行

    composer install
  7. 萬事俱備,只欠東風! 接下來讓我們將example_usage_advanced.php的檔案裡頭的內容複製到你想要的地方,可以是你的Controller,也可以是你的某個class下面的一個function,如下:
    下面的 code 有點長,可以不必全看,除了我中文有特別解釋的地方之外,大概跟原本的sample一樣。

        public function listen(Request $request)
    {
    // 因為官方sample是去接 $_POST,所以這邊直接將Laravel的輸入轉成 POST ,想要自己改的人也可以哦
    $_POST = $request->post();

    // 這個資訊代表是否該交易已付款
    $payment_status = $_POST['payment_status'];

    // 還記得我們之前帶過去的金流訂單號碼嗎?
    $merchant_trade_no = $_POST['custom'];

    // 很重要,等等會用到
    $txn_id = $_POST['txn_id'];

    $txn_type = $_POST['txn_type'];

    // 付款時間
    $payment_date = Carbon::parse($_POST['payment_date'])->setTimezone('UTC');

    // 總付款金額
    $mc_gross = $_POST['mc_gross'];

    // 幣別
    $mc_currency = $_POST['mc_currency'];

    $enable_sandbox = env('PAYPAL_SANDBOX_ENABLESANDBOX');

    // 這邊表示收款人的email,如果IPN裡頭的收款人不在這個清單裡面的話,驗證將會失敗
    // Use this to specify all of the email addresses that you have attached to paypal:
    $my_email_addresses = array(env('PAYPAL_SANDBOX_MAIL'));

    // Set this to true to send a confirmation email:
    $send_confirmation_email = env('PAYPAL_SANDBOX_SEND_CONFIRMATION_EMAIL');
    $confirmation_email_address = "buybuybuygogo@gmail.com";
    $from_email_address = "test@gmail.com";

    // 選true的話,會自動記log
    // Set this to true to save a log file:
    $save_log_file = env('PAYPAL_SANDBOX_SAVE_LOG_FILE');
    $log_file_dir = storage_path() . "/app/payment_logs";

    // Here is some information on how to configure sendmail:
    // http://php.net/manual/en/function.mail.php#118210

    // 這邊就是主要驗證的function
    $ipn = new PaypalIPN();
    if ($enable_sandbox)
    {
    $ipn->useSandbox();
    }
    $verified = $ipn->verifyIPN();

    $data_text = "";
    foreach ($_POST as $key => $value)
    {
    $data_text .= $key . " = " . $value . "\r\n";
    }

    $test_text = "";
    if ($_POST["test_ipn"] == 1)
    {
    $test_text = "Test ";
    }

    // 上面提到的mail,就在這邊確認
    // Check the receiver email to see if it matches your list of paypal email addresses
    $receiver_email_found = false;
    foreach ($my_email_addresses as $a)
    {
    if (strtolower($_POST["receiver_email"]) == strtolower($a))
    {
    $receiver_email_found = true;
    break;
    }
    }

    date_default_timezone_set("America/Los_Angeles");
    list($year, $month, $day, $hour, $minute, $second, $timezone) = explode(":", date("Y:m:d:H:i:s:T"));
    $date = $year . "-" . $month . "-" . $day;
    $timestamp = $date . " " . $hour . ":" . $minute . ":" . $second . " " . $timezone;
    $dated_log_file_dir = $log_file_dir . "/" . $year . "/" . $month;

    $paypal_ipn_status = "VERIFICATION FAILED";
    if ($verified)
    {

    // 進到下面的if之後,表示已經驗證成功了,我們可以在驗證成功之後做一些該做的事
    $paypal_ipn_status = "RECEIVER EMAIL MISMATCH";
    if ($receiver_email_found)
    {
    $paypal_ipn_status = "Completed Successfully";

    $PayPal = (new PayPal())->where('merchant_trade_no', $merchant_trade_no)->first();

    // 這邊檢查了幾項,大概如下:
    // 1. 檢查txn_id,為了避免這筆交易之前就已經有處理過。所以資料庫裡面如果已經有這個txn_id,將不予理會
    // 2. 檢查mc_gross,總金額必須與我們金流訂單裡頭的金額相等
    // 3. 檢查幣別,幣別必須與我們金流訂單裡頭的幣別相同
    // 4. 檢查payment_status,該交易必需已經付款完成
    if ((!PayPal::checkIfTxnIdExists($txn_id)) && ($mc_gross == $PayPal->total_amount) && ($mc_currency == $PayPal->mc_currency) && ($payment_status == 'Completed'))
    {
    // 將該txd_id新增到該金流訂單內,並更新該訂單數據,主要可被識別為已結單。
    $PayPal->update(['txn_id' => $txn_id, 'txn_type' => $txn_type, 'payment_date' => $payment_date, 'status' => 1, 'expiry_time' => null]);
    $recipient = $PayPal->recipient;

    $orderRelations = $PayPal->orderRelations->where('payment_service_id', 2);

    // 更新完金流訂單後,根據該金流訂單取得相對應的使用者訂單,並更新使用者訂單狀態。
    Order::updateStatus($orderRelations, $recipient);

    // 付款完成,寄mail通知使用者
    Helpers::mailWhenPaid($PayPal, $orderRelations);
    }
    }
    } elseif ($enable_sandbox)
    {
    if ($_POST["test_ipn"] != 1)
    {
    $paypal_ipn_status = "RECEIVED FROM LIVE WHILE SANDBOXED";
    }
    } elseif ($_POST["test_ipn"] == 1)
    {
    $paypal_ipn_status = "RECEIVED FROM SANDBOX WHILE LIVE";
    }

    if ($save_log_file)
    {
    // Create log file directory
    if (!is_dir($dated_log_file_dir))
    {
    if (!file_exists($dated_log_file_dir))
    {
    mkdir($dated_log_file_dir, 0777, true);
    if (!is_dir($dated_log_file_dir))
    {
    $save_log_file = false;
    }
    } else
    {
    $save_log_file = false;
    }
    }
    // Restrict web access to files in the log file directory
    $htaccess_body = "RewriteEngine On" . "\r\n" . "RewriteRule .* - [L,R=404]";
    if ($save_log_file && (!is_file($log_file_dir . "/.htaccess") || file_get_contents($log_file_dir . "/.htaccess") !== $htaccess_body))
    {
    if (!is_dir($log_file_dir . "/.htaccess"))
    {
    file_put_contents($log_file_dir . "/.htaccess", $htaccess_body);
    if (!is_file($log_file_dir . "/.htaccess") || file_get_contents($log_file_dir . "/.htaccess") !== $htaccess_body)
    {
    $save_log_file = false;
    }
    } else
    {
    $save_log_file = false;
    }
    }
    if ($save_log_file)
    {
    // Save data to text file
    file_put_contents($dated_log_file_dir . "/" . $test_text . "paypal_ipn_" . $date . ".txt", "paypal_ipn_status = " . $paypal_ipn_status . "\r\n" . "paypal_ipn_date = " . $timestamp . "\r\n" . $data_text . "\r\n", FILE_APPEND);
    }
    }

    if ($send_confirmation_email)
    {
    // Send confirmation email
    mail($confirmation_email_address, $test_text . "PayPal IPN : " . $paypal_ipn_status, "paypal_ipn_status = " . $paypal_ipn_status . "\r\n" . "paypal_ipn_date = " . $timestamp . "\r\n" . $data_text, "From: " . $from_email_address);
    }

    // Reply with an empty 200 response to indicate to paypal the IPN was received correctly
    header("HTTP/1.1 200 OK");

    }

結語

以上大概就是整個利用 PayPal 付款標準版 (Payment Standard) 來付款 ,然後經由 IPN 驗證的流程,
流程大概如下:

  1. 買家完成付款
  2. PayPal 發送 IPN Message 到 Listener
  3. Listener 回饋 HTTP 200 Response 到 PayPal
  4. Listener 將剛剛收到的 IPN Message 原封不動的回傳到 PayPal
  5. PayPal 驗證無誤後,回傳 Verified ,若驗證失敗,回傳 Invalid
PayPal REST API 串接金流好簡單 在 PayPal 的 IPN 方式中,提交多個商品

留言

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×