前言
如题,最近在研究如何对接PayPal支付,在经过一段时间的摸索后,终于实现简单的支付操作,在这里分享下我个人的思路,让其他需要了解的人少走弯路,尽快对接上,申请注册PayPal账号这些我这里不多做解析,网上其他博主已经说得很详细(文字和截图都有),我主要说下如何入手,注意点以及易错点。
一、如何入手
在对接PayPal支付之前,相信很多人都已经对接过微信,支付宝支付,我也不例外,所以一开始接触PayPal支付的时候,我就习惯性的将其代入到以前对接过的支付中,觉得流程应该大同小异,简单点来说就是 发起支付请求 -> 用户授权支付 -> 支付回调 -> 业务处理,但是在查看了PayPal对接文档以及参考了其他博主文章后,发现除了上面我说的流程外,PayPal还多了一步,这一步按照文档的说法叫“批准订单”,但是这样听起来貌似不太好理解,换成通俗点来说就是 主动向PayPal确认收款,发请求给PayPal服务器跟他说可以收款了,如果没有这一步,支付并不算完成,卖家同样收不到款,这一点切记,这样我们就可以将PayPal支付流程简单的概括为 发起支付请求 -> 用户授权支付 -> 支付回调 -> 主动向PayPal确认收款 -> 业务处理
二、开发前准备
在正式开始之前,我们需要在PayPal开发者中心获取ClientId,Secret及设置Webhooks并得到对应Webhook ID,我暂且不细说这些东西的作用,等下根据流程来一一详解,如果不知道在哪里获取这些东西,我引入了其他博主的文章链接,可以直接点击查看,这个博主说的十分详细,里面包含文字和截图,相信看了之后就知道如何获取。
三、支付流程
通过上面得知,我们已将整个流程简单概括为发起支付请求 -> 用户授权支付 -> 支付回调 -> 主动向PayPal确认收款 -> 业务处理这几个步骤,接下来,我们将按照这个思路一步一步分析
通用类和通用方法如下:
---通用类---
public class Links
{
public string href { get; set; }
public string rel { get; set; }
public string method { get; set; }
}
public class CheckoutOrdersResult
{
public string id { get; set; }
public string status { get; set; }
public List<Links> links { get; set; }
}
public class WebhookEvent
{
public string id { get; set; }
public string event_version { get; set; }
public string create_time { get; set; }
public string resource_type { get; set; }
public string resource_version { get; set; }
public string event_type { get; set; }
public string summary { get; set; }
public Resource resource { get; set; }
public List<Links> links { get; set; }
}
public class Resource
{
public string create_time { get; set; }
public List<PurchaseUnits> purchase_units { get; set; }
public List<Links> links { get; set; }
public string id { get; set; }
public string intent { get; set; }
public string status { get; set; }
}
public class PurchaseUnits
{
public string reference_id { get; set; }
}
---通用类---
---通用方法
public string Post(string url, string clientId, string secret, Dictionary <string, object> dic, Encoding encoding)
{
var param = string.Empty;
foreach(var o in dic)
{
if(string.IsNullOrEmpty(param))
param += o.Key + "=" + o.Value;
else
param += "&" + o.Key + "=" + o.Value;
}
byte[] byteArray = encoding.GetBytes(param);
ServicePointManager.ServerCertificateValidationCallback += (s, cert, chain, sslPolicyErrors) => true;
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls;
HttpWebRequest request = (HttpWebRequest) WebRequest.Create(url);
request.Headers.Add("Authorization", "Basic " + Convert.ToBase64String(encoding.GetBytes(clientId + ":" + secret)));
request.PreAuthenticate = true;
request.Method = "POST";
request.ContentType = "application/x-www-form-urlencoded";
request.ContentLength = byteArray.Length;
Stream newStream = request.GetRequestStream();
newStream.Write(byteArray, 0, byteArray.Length);
newStream.Close();
using(HttpWebResponse response = (HttpWebResponse) request.GetResponse())
{
using(var stream = response.GetResponseStream())
{
if(stream != null)
{
using(StreamReader sr = new StreamReader(stream, Encoding.UTF8))
{
return sr.ReadToEnd();
}
}
}
}
return string.Empty;
}
public string PostJsonByBearer(string url, string json, Encoding encoding, string accessToken)
{
try
{
byte[] byteArray = encoding.GetBytes(json);
ServicePointManager.ServerCertificateValidationCallback += (s, cert, chain, sslPolicyErrors) => true;
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls;
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
request.Headers.Add("Authorization", $"Bearer {accessToken}");
request.PreAuthenticate = true;
request.Method = "POST";
request.Headers.Add("Cache-Control", "no-cache");
request.ContentType = "application/json";
request.ContentLength = byteArray.Length;
Stream newStream = request.GetRequestStream();
newStream.Write(byteArray, 0, byteArray.Length);
newStream.Close();
using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
{
using (var stream = response.GetResponseStream())
{
if (stream != null)
using (StreamReader sr = new StreamReader(stream, Encoding.UTF8))
{
return sr.ReadToEnd();
}
}
}
}
catch (WebException e)
{
var eMsg = string.Empty;
var eResponse = e.Response as HttpWebResponse;
using (var eStream = eResponse.GetResponseStream())
{
if (eStream != null)
using (StreamReader sr = new StreamReader(eStream, Encoding.UTF8))
{
eMsg = sr.ReadToEnd();
}
}
}
return string.Empty;
}
public string GetByBearer(string url, string accessToken)
{
ServicePointManager.ServerCertificateValidationCallback += (s, cert, chain, sslPolicyErrors) => true;
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls;
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
request.Headers.Add("Authorization", $"Bearer {accessToken}");
request.PreAuthenticate = true;
request.Method = "GET";
using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
{
using (var stream = response.GetResponseStream())
{
if (stream != null)
using (StreamReader sr = new StreamReader(stream, Encoding.UTF8))
{
return sr.ReadToEnd();
}
}
}
return string.Empty;
}
---通用方法
1.获取access_token
之所以先提这个,是因为接下来的支付流程都跟access_token紧密相关,这里我们就需要用到上面说的ClientId和Secret,查看对应接口文档,参考代码如下,这样我们就获取到凭证access_token,下面用到的accessToken都来源于此。
var ret = Post("域名/v1/oauth2/token/", ClientId, Secret, new Dictionary<string, object> { { "grant_type", "client_credentials" }, System.Text.Encoding.UTF8})
2.发起支付请求
通过第1点我们已经拿到了access_token,在成功创建支付订单返回的内容中拿到rel为“approve”的链接进行跳转查看对应接口文档,参考代码如下
代码如下(示例):
var param = new
{
intent = "CAPTURE",
// 总价
purchase_units = new dynamic[]
{
new
{
reference_id = DateTime.Now.ToString("yyyyMMddHHmmssffffff"),
description = "订单",
amount = new
{
currency_code = "HKD",
value = "200",
// 价格明细,如果要在下面加商品明细,则必须在这里加上价格明细,否则提交出错
// 商品明细中的商品合计金额必须等于价格明细中的金额,按照以下例子模拟计算75*2+50*1=200,否则提交出错
// 商品明细和价格明细的货币必须一致,否则提交出错
breakdown = new
{
item_total = new
{
currency_code = "HKD",
value = "200"
}
}
},
// 商品明细
items = new dynamic[]
{
new
{
name = "衣服",
unit_amount = new
{
currency_code = "HKD",
value = "75"
},
quantity = 2 // 数量
},
new
{
name = "裤子",
unit_amount = new
{
currency_code = "HKD",
value = "50"
},
quantity = 1 // 数量
}
}
}
},
application_context = new
{
return_url = $"{Request.Url.Scheme}://{Request.Url.Authority}/Success.aspx", //支付完成跳转链接
cancel_url = $"{Request.Url.Scheme}://{Request.Url.Authority}/Cancel.aspx" //取消支付跳转链接
}
};
var json = new System.Web.Script.Serialization.JavaScriptSerializer().Serialize(param);
var ret = PostJsonByBearer("域名/v2/checkout/orders", json, System.Text.Encoding.UTF8, accessToken);
var checkoutOrdersResult = new System.Web.Script.Serialization.JavaScriptSerializer().Deserialize<CheckoutOrdersResult>(ret);
if (checkoutOrdersResult != null)
{
var approveUrl = checkoutOrdersResult.links.FirstOrDefault(x => x.rel.Equals("approve")).href;
Response.Redirect($"approveUrl:{approveUrl}");
}
3.用户授权支付
通过第2点我们已经拿到rel为“approve”的链接并进行跳转,这时就到用户授权支付的操作(流程类似进入到支付宝网页版本进行输入账号密码登录并确定支付),这一步不需要我们做什么,只需要等待用户操作即可。
4.支付回调
用户支付完成后,就到了支付回调,目前分为WebHooks和IPN两种方式,看了网上的资料,大部分比较推荐使用WebHooks,所以我就直接选用WebHooks,详细解释我也不多说,我直接说怎么使用,第一步就是得配置接收通知地址,设置完之后得到 Webhook ID,Webhook ID用于验证Webhook消息,记得存下来;第二步选择对应的事件订阅,至于支付应该订阅哪些事件,主流的选择是CHECKOUT.ORDER.APPROVED 和 PAYMENT.CAPTURE.COMPLETED,但是根据我实际用到的,感觉订阅CHECKOUT.ORDER.APPROVED事件就够了;第三步回调验证,分为调用验签API和通过算法自行验证两种,我用的是第一种调用验签API,需要用到前面提到的Webhook ID,以此来作为消息验证,查看对应接口文档,参考代码如下
public bool VerifySign(string requestBody, Encoding encoding,string accessToken)
{
string json = string.Empty;
string webhook_id = "XXXXXXXXXXXXXXXXX";
string url = "域名/v1/notifications/verify-webhook-signature";
string transmission_id = Request.Headers["PAYPAL-TRANSMISSION-ID"];
string transmission_time = Request.Headers["PAYPAL-TRANSMISSION-TIME"];
string cert_url = Request.Headers["PAYPAL-CERT-URL"];
string auth_algo = Request.Headers["PAYPAL-AUTH-ALGO"];
string transmission_sig = Request.Headers["PAYPAL-TRANSMISSION-SIG"];
string webhook_event = requestBody;
json = "{\"transmission_id\":\"" + transmission_id + "\",\"transmission_time\":\"" + transmission_time + "\",\"cert_url\":\"" + cert_url + "\",";
json += "\"auth_algo\":\"" + auth_algo + "\",\"transmission_sig\":\"" + transmission_sig + "\",\"webhook_id\":\"" + webhook_id + "\",";
json += "\"webhook_event\":" + webhook_event + "}";
var jsSerializer = new System.Web.Script.Serialization.JavaScriptSerializer();
var ret = PostJsonByBearer(url, json, encoding, accessToken);
var retDic = jsSerializer.Deserialize<Dictionary<string, string>>(ret);
if (retDic != null && retDic.ContainsKey("verification_status") && retDic["verification_status"].ToUpper().Equals("SUCCESS"))
return true; //验证成功
else
return false; //验证失败
}
public void PaypalNotify()
{
string requestBody = string.Empty;
Encoding encoding = Encoding.UTF8;
using (StreamReader reader = new StreamReader(Request.InputStream, encoding))
{
requestBody = reader.ReadToEnd();
}
var result = VerifySign(requestBody, encoding, accessToken);
if (result)
{
var data = new System.Web.Script.Serialization.JavaScriptSerializer().Deserialize<WebhookEvent>(requestBody);
if (data == null)
return;
switch (data.event_type.ToUpper())
{
case "CHECKOUT.ORDER.APPROVED":
break;
case "PAYMENT.CAPTURE.COMPLETED":
break;
default:
break;
}
}
}
5.主动向PayPal确认收款及业务处理
支付回调验证通过后,我们在上面的"CHECKOUT.ORDER.APPROVED"事件中加入以下代码,先查询下账单的状态查看对应接口文档,当状态为"APPROVED"时,主动向paypal确认收款(批准账单)查看对应接口文档,账单完成后执行业务
var data = new System.Web.Script.Serialization.JavaScriptSerializer().Deserialize<WebhookEvent>(requestBody);
//查询账单的详情
var ret = GetByBearer($"域名/v2/checkout/orders/{data.resource.id}", accessToken);
var retJson = new System.Web.Script.Serialization.JavaScriptSerializer().Deserialize<Resource>(ret);
if (retJson.status.ToUpper().Equals("APPROVED"))
{
//主动向paypal确认收款
ret = PostJsonByBearer($"域名/v2/checkout/orders/{data.resource.id}/capture", "", encoding, accessToken);
retJson = new System.Web.Script.Serialization.JavaScriptSerializer().Deserialize<Resource>(ret);
if (retJson != null)
{
if (retJson.status.ToUpper().Equals("COMPLETED"))
{
//执行业务
}
}
}
四、注意点
1、域名包含沙盒地址和真实地址,分别对应https://api-m.sandbox.paypal.com和https://api-m.paypal.com;
2、在开发中中心配置WebHooks接收通知地址时要注意,只支持https;
3、需要主动向paypal确认收款已完成支付流程;
4、支付成功后执行业务放在主动向paypal确认收款后而不是PAYMENT.CAPTURE.COMPLETED事件,之所以这么说,是因为相比主动向paypal确认收款,PAYMENT.CAPTURE.COMPLETED事件有一定的延迟,这样就意味着你支付完后,可能在一定时间内,你的订单支付状态依旧是未支付,这也是前面我为啥说只订阅CHECKOUT.ORDER.APPROVED事件就够的原因,当然这个只是我个人的见解;
5、捕获PayPal接口异常时,不要直接try{}catch(Exception e){},这样永远只能得到“远程服务器返回错误: (404) 未找到”,而是要用try{}catch (WebException e){}获取响应体返回的内容(参考上面通用方法PostJsonByBearer);
五、易错点
1、发起支付请求,价格明细和商品明细的对应关系,详情看发起支付请求示例代码备注;
2、支付回调中调用验签API时,由于PayPal提交过来的webhook_event参数本身就是json字符串,在提交验签API的时候注意不要重复去序列化webhook_event参数;
3、不同货币有不同的要求,大部分支持小数,但像日元这种是不支持小数的,请点击货币代码查看,还有一点让我比较疑惑,人民币是不支持的,但是里面又有提供货币代码,好像是因为国内支持的是 贝宝 而不会 PayPal;
总结
相比国内的其他支付,PayPal在开发文档和demo等资料上相对贫瘠,且在并不多的资料中还充斥着不少被弃用的v1版本,导致在对接过程中需要浪费不少时间去排雷,这次我以v2版本作为基础进行开发,整理出我在开发过程中遇到的问题以及经验,希望能帮助到有需要的人。