【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
   本质上两种方式都可以确认订单状态,但是建议两种方式都使用,分别用于同步和异步通知确认订单状态,逻辑会比使用一种方式验证复杂些,但是安全性得到很好的保证


  

  

  


  


 

   

  

 

  

posted @ 2022-06-09 17:12  听风是雨  阅读(4320)  评论(1编辑  收藏  举报
/* 看板娘 */