【PayPal支付】PayPal支付通道对接
需求背景
由于公司要推出一款海外市场的APP,需要接入海外APP的支付方式
例如:
(安卓)PayPal,GooglePay,境外微信/境外支付宝
(IOS)ApplePay
本文主要总结对接Paypal过程中的步骤和注意事项
项目架构
后端:SpringCloud + SpringBoot(版本号:2.1.x)+MySQL +Redis+Maven
<!--paypal支付sdk--> <dependency> <groupId>com.paypal.sdk</groupId> <artifactId>rest-api-sdk</artifactId> <version>1.14.0</version> </dependency> <dependency> <groupId>com.paypal.sdk</groupId> <artifactId>checkout-sdk</artifactId> <version>1.0.2</version> </dependency>
前端:安卓APP
集成方式
查询PayPal官方文档:https://developer.paypal.com/limited-release/native-checkout/android/ 可以看到PayPal这里提供三种集成方式
1.Client-side integration(客户端集成)
特点:
主要支付流程是在客户端完成,需要客户端集成PayPal SDK,以及需要集成PayPal自带的支付按钮,对代码的侵入性比较强,不需要服务端参与
缺点:
整个下单支付流程是在客户端完成,服务端没有感知,因此在弱网环境下会有掉单的风险
2.Server-side integration(服务端集成)
特点
主要支付流程是在服务端完成,客户端没有什么工作两,服务端需要集成PayPal SDK,主要主动查询订单状态,发起扣款(ReturnUrl)和事件通知回调这些手段来保证调单的情况
整个订单支付流程可靠
缺点:
需要服务段对接的工作稍多
3.Programmatically start the SDK(客户端集成)
特点:
和第一种客户端集成方式类似,主要支付流程在客户端完成, 可以自定义相关的UI
缺点:
整个下单支付流程是在客户端完成,服务端没有感知,因此在弱网环境下会有掉单的风险
项目中,主要使用第二种方式(服务端集成)进行PayPal的对接
支付时序
从支付时序中可以看到,后台和PayPal交互的接口主要有一下面几个
1.创建订单: /v2/checkout/orders
调用时机:用户下单,服务端请求PayPal生成预支付订单
功能:类似与国内微信/支付宝的预支付接口,这个接口会返回创建订单的状态,如果订单创建成功还会在approve属性中返回一串链接,用于返回给客户端打开这个链接,进行支付
注意:这个接口会传递给PayPal returnUrl 和 cancelUrl 分别用于在支付完成后和支付取消后重定向到服务端,用于下一步操作
2.捕获订单: /v2/checkout/orders/{order_id}/capture
调用时机:用户支付完成后,PayPal会重定向到服务端的returnUrl,这时可以请求PayPal进行扣款(这一步和国内微信/支付宝有点差异)
功能:用于当前用户支付完成后,在服务端要进行捕获订单的操作
3.查询支付状态: /v2/payments/captures/{capture_id}
调用时机:查询订单的支付状态
功能:查询订单的支付状态
注意:如果配置了支付完成的事件回调,则应该使用回调作为订单最终支付完成确认的方式,改接口,可以作为服务端主动发起查询的手段,
例如超时主动查询,防止调单的情况;由于这里主要介绍支付对接流程,对于调单补单情况就不做赘述,网上刚博客也有很多方案
4.支付完成(Payment capture completed)-事件通知回调: 用户配置在PayPal后台的链接
调用时机:用户在支付完成后,服务端也完成了订单捕获,这时就会发起Payment capture completed 回调
功能:异步回调实现订单支付完成通知
注意:事件通知回调需要在PayPal后台开启,并配置服务端通知URL ,如果开启了通知,所有的PayPal通知都会回调到这个URL,
如果不需要关注其他事件,这里有两种处理方式
1.默认配置全部事件通知:服务端则要根据回调的事件名,return跳过其他事件,仅处理支付完成回调的事件(具体见后续的代码Demo)
2.配置指定的事件通知 (项目中使用这一种)
5.事件回调签名验证
调用时机:事件回调后,验证本次回调的有效性
示例代码
创建订单
/** * PayPal 支付 * * @author Sam.yang * @since 2022/4/19 10:51 */ @Slf4j @Component public class PayPalPayIAct implements IAct { @Autowired private PayPalProperties payPalProperties; @SneakyThrows @Override public ChannelPayResponse execute(ChannelPayRequest payRequest) { log.info("==========>PayPal支付 支付参数:{}", JSON.toJSONString(payRequest)); log.info("==========>PayPal支付 订单号:【{}】", payRequest.getOrderNo()); HttpResponse<Order> response = this.getOrderHttpResponse(payRequest); ChannelPayResponse channelPayResp = ChannelPayResponse.prePaySuccess(); String approve = ""; if (HttpStatus.CREATED.value() == response.statusCode()) { log.info("==========>订单创建成功,响应状态码:【{}】,状态:【{}】,订单Id:【{}】,Intent:【{}】", response.statusCode(), response.result().status(), response.result().id(), response.result().checkoutPaymentIntent()); Order order = response.result(); Optional<String> optional = order.links().stream() .peek(a -> log.info("链接关联:【{}】,链接方式:【{}】,链接地址:【{}】", a.rel(), a.method(), a.href())) .filter(linkDescription -> "approve".equals(linkDescription.rel())).map(LinkDescription::href).findFirst(); if (optional.isPresent()) { approve = optional.get(); channelPayResp = ChannelPayResponse.prePaySuccess(); } } else { log.info("==========>订单创建【失败】 响应结果信息:{}", response.result()); return ChannelPayResponse.fail(); } channelPayResp.setPayParam(approve); channelPayResp.setPayParamType(5); return channelPayResp; } @Retryable(value = IOException.class) public HttpResponse<Order> getOrderHttpResponse(ChannelPayRequest payRequest) throws IOException { OrdersCreateRequest request = new OrdersCreateRequest(); request.header("prefer", "return=representation"); request.requestBody(buildRequestBody(payRequest)); PayPalClient payPalClient = new PayPalClient(); HttpResponse<Order> response = payPalClient.client(payPalProperties.getMode(), payPalProperties.getClientId(), payPalProperties.getClientSecret()).execute(request); return response; } @Override public ChannelPayType0Enum getPayType() { return ChannelPayType0Enum.PAYPAL_APP_PAY; } @Override public String getChannelEnName() { return AccountConstant.CHANNEL_PAYPAL; } private OrderRequest buildRequestBody(ChannelPayRequest payRequest) { OrderRequest orderRequest = new OrderRequest(); orderRequest.checkoutPaymentIntent(PayPalCheckoutConstant.CAPTURE); ApplicationContext applicationContext = new ApplicationContext() .brandName(PayPalCheckoutConstant.BRAND_NAME) .cancelUrl(payPalProperties.getCancelUrl()) .returnUrl(payPalProperties.getReturnUrl()); orderRequest.applicationContext(applicationContext); List<PurchaseUnitRequest> purchaseUnits = new ArrayList<>(); purchaseUnits .add(new PurchaseUnitRequest() .description(payRequest.getPayDesc()) // 商品描述 .customId(payRequest.getPayNo()) // 自定义编号,取本地订单号 .invoiceId(payRequest.getPayNo()) // 本地订单号 .amountWithBreakdown(new AmountWithBreakdown() .currencyCode(payRequest.getCurrencyCode()) .value(String.valueOf(payRequest.getPayAmt())) )); orderRequest.purchaseUnits(purchaseUnits); return orderRequest; } }
支付成功同步通知 returnUrl
/** * 同步PDT:支付成功 * * @param token * @param payerId * @return */ @GetMapping("/paypal/success") @ApiOperation(value = "PayPal支付成功重定向") public String successPay(@RequestParam("token") String token, @RequestParam("PayerID") String payerId) { log.info("==========>PayPal支付成功 token:【{}】,PayerID:【{}】", token, payerId); try { ChannelPayResponse channelPayResp = captureOrder.captureOrder(token); captureOrder.getCapture(channelPayResp.getChannelPayNo()); } catch (Exception e) { log.warn("==========>PayPal支付成功处理失败,异常:{}", e); } return PayPalCheckoutConstant.SUCCESS; }
@Slf4j @Component public class CaptureOrder { @Autowired private PayPalProperties payPalProp; public OrderRequest buildRequestBody() { return new OrderRequest(); } /** * 捕获订单 * * @param orderId */ @Retryable(value = Exception.class) public ChannelPayResponse captureOrder(String orderId) throws IOException { log.info("==========>开始执行PayPal订单扣款,商户订单号:【{}】", orderId); ChannelPayResponse channelPayResp = new ChannelPayResponse(); OrdersCaptureRequest request = new OrdersCaptureRequest(orderId); request.requestBody(new OrderRequest()); PayPalClient payPalClient = new PayPalClient(); HttpResponse<Order> response = payPalClient.client(payPalProp.getMode(), payPalProp.getClientId(), payPalProp.getClientSecret()).execute(request); for (PurchaseUnit purchaseUnit : response.result().purchaseUnits()) { for (Capture capture : purchaseUnit.payments().captures()) { log.info("==========>扣款结果:商户订单号:【{}】,订单状态:【{}】,自定义编号:【{}】,PayPal交易号:【{}】", capture.id(), capture.status(), capture.invoiceId(), capture.id()); log.info(capture.invoiceId()); channelPayResp.setChannelPayNo(capture.id()); //第三方单号 if (PayPalPaymentStatus.COMPLETED.getValue().equalsIgnoreCase(capture.status())) { log.info("==========>扣款【成功】"); channelPayResp.setPayState(2); } else if (PayPalPaymentStatus.PENDING.getValue().equalsIgnoreCase(capture.status())) { log.info("==========>扣款【处理中】"); if (capture.captureStatusDetails() != null && capture.captureStatusDetails().reason() != null) { log.info("==========>扣款Pending状态详情: {}", capture.captureStatusDetails().reason()); } channelPayResp.setPayState(5); } else { log.info("==========>扣款【失败】"); channelPayResp.setPayState(3); } } } return channelPayResp; } /** * 支付后查询扣款信息 */ @Retryable(value = Exception.class) public Boolean getCapture(String captureId) { log.info("==========>开始执行PayPal订单状态查询,第三方订单号:【{}】", captureId); CapturesGetRequest restRequest = new CapturesGetRequest(captureId); PayPalClient payPalClient = new PayPalClient(); HttpResponse<com.paypal.payments.Capture> response = null; try { response = payPalClient.client(payPalProp.getMode(), payPalProp.getClientId(), payPalProp.getClientSecret()).execute(restRequest); } catch (IOException e) { log.info("==========>订单支付状态查询异常:{}", e); return false; } //这里查询订单支付状态,不处理查询结果,最终以回调为准 log.info("==========>查询订单状态付款状态,第三方商户单号:【{}】 ", response.result().id()); return true; } }
支付取消重定向cancalUrl
/** * 同步PDT:支付取消 * * @param paymentId * @param payerId * @return */ @GetMapping("/paypal/cancel") @ApiOperation(value = "PayPal支付取消重定向") public String cancelPay(@RequestParam(value = "paymentId", required = false) String paymentId, @RequestParam("PayerID") String payerId, HttpServletRequest request) throws IOException { log.info("==========>PayPal支付取消 paymentId:【{}】,PayerID:【{}】", paymentId, payerId); String respBody = PayPalHttpUtil.getBody(request); log.info("==========> 请求体参数:{}", respBody); return PayPalCheckoutConstant.CANCEL; }
支付回调--异步通知
/** * 支付回调-异步IPN * * @param request {@link HttpServletRequest} * @return * @throws Exception */ @PostMapping("/paypal") @ApiOperation(value = "PayPal支付回调") public String paypalNotify(HttpServletRequest request) throws Exception { log.info("==========>收到PayPal异步通知"); try { APIContext apiContext = new APIContext(payPalProp.getClientId(), payPalProp.getClientSecret(), payPalProp.getMode()); apiContext.addConfiguration(Constants.PAYPAL_WEBHOOK_ID, payPalProp.getWebHookId()); String respBody = PayPalHttpUtil.getBody(request); Map<String, String> respHeader = PayPalHttpUtil.getHeadersInfo(request); log.info("支付回调 请求体参数:{}", respBody); log.info("支付回调 请求头参数:{}", respHeader); log.info("==========>签名验证【开始】"); boolean success = Event.validateReceivedEvent(apiContext, respHeader, respBody); if (!success) { log.info("==========>签名验证【失败】"); return PayPalCheckoutConstant.FAILURE; } log.info("==========>签名验证【成功】"); String eventType = JSONPath.read(respBody, "$.event_type").toString(); log.info("当前回调通知类型为:【{}】", eventType); if (!PayPalConstant.Event.PAY_SUCCESS.getValue().equalsIgnoreCase(eventType)) { return PayPalCheckoutConstant.SUCCESS; } Object paymentStatus = JSONPath.read(respBody, "$.resource.status"); log.info("当前支付状态:【{}】", paymentStatus); if (!PayPalPaymentStatus.COMPLETED.getValue().equalsIgnoreCase(paymentStatus.toString())) { return PayPalCheckoutConstant.FAILURE; } Object amount = JSONPath.read(respBody, "$.resource.amount.value"); Object paymentId = JSONPath.read(respBody, "$.resource.id"); Object outTradeNo = JSONPath.read(respBody, "$.resource.invoice_id"); log.info("第三方订单号:{},商户对那个单号:{},订单金额:{}", paymentId, outTradeNo, amount); if (PayPalPaymentStatus.COMPLETED.getValue().equalsIgnoreCase(paymentStatus.toString())) { log.info("订单支付成功,订单状态为:【COMPLETED】"); OrderPayNotifyRequest result = new OrderPayNotifyRequest(); result.setChannelOrderNo(paymentId.toString()); result.setOurOrderNo(outTradeNo.toString()); result.setPayAmt(new BigDecimal(amount.toString())); result.setPayType(10); notifyService.notify(result); return PayPalCheckoutConstant.SUCCESS; } } catch (Exception e) { log.error("==========>回调处理失败:{}", e); return PayPalCheckoutConstant.FAILURE; } return PayPalCheckoutConstant.FAILURE; }
注意事项
1.测试环境
测试环境主要在沙箱环境中进行,沙箱环境中具备完成的支付/退款能力,提供了用于测试的账号,相对于来说比较方便
这里可以使用默认的项目也可以重新创建一个项目,如图
2.相关参数:
这里主要是服务端用到支付参数有:
clientId、clientSecret,用户身份的认证
如下图
如果需要使用到回调通知也就是webHook,则需要配置回调通知Url和webHookId(用于事件通知的签名验证)
3.测试的支付账户
PayPal提供了用户支付的测试账户,如下图
注意,在使用测试账户支付时,有效的验证码,这里输入全数字
4.关于支付完成的确认
PayPal 提供了两种关于支付完成的方式
第一种,同步通知,简称PTD
第二种,异步通知,简称IPN
本质上两种方式都可以确认订单状态,但是建议两种方式都使用,分别用于同步和异步通知确认订单状态,逻辑会比使用一种方式验证复杂些,但是安全性得到很好的保证