Paypal及Airwallex订阅支付开发笔记

726 阅读8分钟

paypal支付

在来到公司时,公司已经接入了paypal支付,使用的是github上的一个框架Jpay:github.com/Javen205/IJ… paypal支付我们主要使用了两种方式:paypal_site和paypal_code,在接入框架后,剩下的事情就很简单,构建请求参数,发起请求,解析相应。这里不过多赘述,简单做个引入,感兴趣的朋友可以去找其他资料了解。

PayPalApiConfig config = initConfig();
Order order = new Order();
String data = this.getPaypalParams(order);
log.info("create paypal site pay params:{}", data);
IJPayHttpResponse resData = PayPalApi.createOrder(config, data);

Airwallex支付

为了减轻用户经济负担,公司产品销售策略是用户可以先付首付款,然后在使用一段时间后,如果满意,将剩余的25%的尾款支付即可,不满意可以无条件退款,且经过一段时间发展后,用户逐渐扩展,国内外都有,考虑到之后支付方式可能扩展且开发时间比较紧,遂决定使用awx。本次主要使用了awx的hpp方式。

如果公司已经和awx有接触,awx会提供开发所需要的各种资料,在服务方面没的说,效率很快,下面提供的是我们开发中使用到的一些资料。

文档资料

www.airwallex.com/docs/api#/I…

image.png

开发过程中一定要注意版本,各个版本之间还是有兼容性问题的,如果遇到可以向awx技术支持人员反馈。

DOCS

www.airwallex.com/docs/paymen…

后台

Airwallex后台

hpp

github.com/airwallex/a…

hpp支付接入

image.png 为了和之前的系统风格保持一致,且为了兼容业务中的支付代码,最大可能代码复用,本次开发仿Jpay风格。以下是仿照的一些代码

image.png

import cn.hutool.core.util.StrUtil;
import cn.hutool.http.ContentType;
import com.ijpay.core.IJPayHttpResponse;
import com.ijpay.core.kit.HttpKit;
import com.ijpay.core.kit.PayKit;

import java.util.HashMap;
import java.util.Map;

public class AirwallexApi {
    public static IJPayHttpResponse createOrder(AirwallexConfig config, String data, String token) {
        return post(getReqUrl(AirwallexApiUrl.CHECKOUT_ORDERS.getUrl(), config.getIsDemo()), data, getBaseHeaders(token));
    }

    public static IJPayHttpResponse queryOrder(AirwallexConfig config, String paymentIntentId, String token) {
        String url = getReqUrl(AirwallexApiUrl.CHECK_PAYMENT_STATUS.getUrl(), config.getIsDemo()).concat("/").concat(paymentIntentId);
        return get(url, null, getBaseHeaders(token));
    }

    public static IJPayHttpResponse cancelOrder(AirwallexConfig config, String paymentIntentId, String token, String data) {
        String format = String.format(AirwallexApiUrl.CANCEL_PAYMENT_INTENT.getUrl(), paymentIntentId);
        return post(getReqUrl(format, config.getIsDemo()), data, getBaseHeaders(token));
    }

    public static IJPayHttpResponse confirmOrder(AirwallexConfig config, String paymentIntentId, String token, String data) {
        String format = String.format(AirwallexApiUrl.CONFIRM_PAYMENT_INTENT.getUrl(), paymentIntentId);
        return post(getReqUrl(format, config.getIsDemo()), data, getBaseHeaders(token));
    }

    public static IJPayHttpResponse createCustomer(AirwallexConfig config, String token, String data) {
        String format = String.format(AirwallexApiUrl.CREATE_CUSTOMER.getUrl());
        return post(getReqUrl(format, config.getIsDemo()), data, getBaseHeaders(token));
    }

    public static IJPayHttpResponse generateClientSecret(AirwallexConfig config, String customerId, String token) {
        String format = String.format(AirwallexApiUrl.GENERATE_CLIENT_SECRET.getUrl(), customerId);
        return get(getReqUrl(format, config.getIsDemo()), null, getBaseHeaders(token));
    }

    public static IJPayHttpResponse retrieveCustomerList(AirwallexConfig config, String merchantCustomerId, String token) {
        String url = getReqUrl(AirwallexApiUrl.RETRIEVE_CUSTOMER_LIST.getUrl(), config.getIsDemo());
        HashMap<String, Object> params = new HashMap<>();
        params.put("merchant_customer_id", merchantCustomerId);
        return get(url, params, getBaseHeaders(token));
    }

    public static IJPayHttpResponse refund(AirwallexConfig config, String data, String token) {
        return post(getReqUrl(AirwallexApiUrl.REFUND.getUrl(), config.getIsDemo()), data, getBaseHeaders(token));
    }

    public static IJPayHttpResponse getToken(AirwallexConfig config) {
        Map<String, String> headers = new HashMap<>(3);
        headers.put("Content-Type", ContentType.JSON.toString());
        headers.put("x-client-id", config.getClientId());
        headers.put("x-api-key", config.getApiKey());
        return post(getReqUrl(AirwallexApiUrl.GET_TOKEN.getUrl(), config.getIsDemo()), "", headers);
    }

    public static IJPayHttpResponse get(String url, Map<String, Object> params, Map<String, String> headers) {
        return HttpKit.getDelegate().get(url, params, headers);
    }

    public static IJPayHttpResponse post(String url, String data, Map<String, String> headers) {
        return HttpKit.getDelegate().post(url, data, headers);
    }

    public static String getReqUrl(String airwallexApiUrl, boolean isDemo) {
        return (isDemo ? AirwallexApiUrl.DEMO_URL.getUrl() : AirwallexApiUrl.PROD_URL.getUrl()).concat(airwallexApiUrl);
    }

    public static Map<String, String> getBaseHeaders(String accessToken) {
        return getBaseHeaders(accessToken, PayKit.generateStr(), (String) null, (String) null);
    }

    public static Map<String, String> getBaseHeaders(String accessToken, String payPalRequestId, String payPalPartnerAttributionId, String prefer) {
        if (accessToken != null && !StrUtil.isEmpty(accessToken) && !StrUtil.isEmpty(accessToken)) {
            Map<String, String> headers = new HashMap<>(3);
            headers.put("Content-Type", ContentType.JSON.toString());
            headers.put("Authorization", "Bearer".concat(" ").concat(accessToken));
            return headers;
        } else {
            throw new RuntimeException("accessToken is null");
        }
    }
}
import lombok.Getter;

@Getter
public enum AirwallexApiUrl {

    /**
     * demo环境地址
     */
    DEMO_URL("https://api-demo.airwallex.com"),

    /**
     * 生产环境地址
     */
    PROD_URL("https://api.airwallex.com"),

    /**
     * 认证获取token
     */
    GET_TOKEN("/api/v1/authentication/login"),

    /**
     * 获取账户余额
     */
    GET_BALANCES("/api/v1/balances/current"),

    /**
     * 创建订单请求路径
     */
    CHECKOUT_ORDERS("/api/v1/pa/payment_intents/create"),

    /**
     * 检查付款状态
     */
    CHECK_PAYMENT_STATUS("/api/v1/pa/payment_intents/"),

    /**
     * Confirm Payment Intent
     */
    CONFIRM_PAYMENT_INTENT("/api/v1/pa/payment_intents/%s/confirm"),

    /**
     * 取消付款
     */
    CANCEL_PAYMENT_INTENT("/api/v1/pa/payment_intents/%s/cancel"),

    /**
     * 退款
     */
    REFUND("/api/v1/pa/refunds/create"),

    /**
     * Create Customer
     */
    CREATE_CUSTOMER("/api/v1/pa/customers/create"),

    /**
     * Retrieve Customer
     */
    RETRIEVE_CUSTOMER_LIST("/api/v1/pa/customers"),

    /**
     *
     */
    GENERATE_CLIENT_SECRET("/api/v1/pa/customers/%s/generate_client_secret");

    /**
     * url
     */
    private final String url;

    AirwallexApiUrl(String url) {
        this.url = url;
    }

}
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.annotation.Resource;

@Configuration
public class AirwallexConfigFactory {

    @Resource
    private AirwallexBean airwallexBean;

    @Bean
    public AirwallexConfig airwallexConfig() {
        AirwallexConfig airwallexConfig = new AirwallexConfig();
        airwallexConfig.setClientId(airwallexBean.getClientId());
        airwallexConfig.setApiKey(airwallexBean.getApiKey());
        airwallexConfig.setIsDemo(airwallexBean.getIsDemo());
        return airwallexConfig;
    }
}

hpp算是比较简单的开发方式,简言之可以理解为:根据用户订单意向创建paymentIntent,将paymentIntent相关参数携带到客户端,用户点击awx提供的页面跳转到支付页,支付完成后收到webhook回调,根据回调中的merchantOrderId找到自己存储的订单,完成后续业务逻辑。 下面是一个简单的伪代码

String requestId = UUID.randomUUID().toString().replace("-", "");
String data = getAirwallexPayParams(order, requestId);
log.info("create airwallex site pay params:{}", data);
String token = airwallexTokenService.getToken();
IJPayHttpResponse resData = AirwallexApi.createOrder(getAirwallexConfig(), data, token);
AssertUtil.assertTrue(resData.getStatus() != 201, "create orderDTO fail");
PaymentIntentVO paymentIntent = JSONUtil.toBean(resData.getBody(), PaymentIntentVO.class);

...

UnifiedOrderResponse response = new UnifiedOrderResponse();
UnifiedOrderResponse.AirwallexPaymentParams airwallexPaymentParams = new UnifiedOrderResponse.AirwallexPaymentParams();
airwallexPaymentParams.setPaymentIntentId(paymentIntent.getId());
airwallexPaymentParams.setClientSecret(paymentIntent.getClientSecret());
airwallexPaymentParams.setCurrency(paymentIntent.getCurrency());
airwallexPaymentParams.setMethods(List.of(methods));
airwallexPaymentParams.setMode("payment");
airwallexPaymentParams.setEnv(getAirwallexConfig().getIsDemo() ? "demo" : "prod");
response.setAirwallexPaymentParams(airwallexPaymentParams);


客户端将response中的参数填入awx提供的页面即可,开发时可本地创建一个html页面填入参数后浏览器打开即可支付

image.png

webhook

image.png

  • 本地调试时可以考虑使用贝锐花生壳,内网穿透实现回调调用到本地
  • webhook可以使用webhook.site/ 来查看回调的具体内容,在调试paypal时笔者使用的多一些;

简单的支付到这里,接下来是关于订阅支付的一些内容,如果有写的不好的地方还劳烦各位指点。

订阅支付

笔者使用postman比较多,先提供paypal和awx的postman的 api fork

// awx
https://www.airwallex.com/docs/developer-tools__api__quickstart-with-postman

// paypal
https://developer.paypal.com/api/rest/

在我接触到的业务中,订阅支付主要有两方面业务

  • 固定周期订阅
  • 非固定周期订阅

简单举两个例子,固定周期订阅就像合约手机,必须在2年内,每年每个月都是用固定费用的套餐,拖欠的必须被补缴,2年后订阅结束,手机使用权归你;非固定周期订阅像各大视频网站会员,你可以每个月付款享受会员权益,暂停付款对你之后重新使用没有影响。

paypal和awx订阅设计实现的角度不同,paypal完全将订阅发起控制在paypal方,作为商家来说我们需要先在paypal创建产品,然后针对这个产品,创建计划,然后根据计划,为用户创建订阅;而awx订阅严格意义上来说更像是用户对商家开启了免密支付授权,订阅由商家自行发起(本文写于25年2月,如果之后有变动,以官方文档为准),时间、金额、次数都有由商家自行控制。

Paypal

自定义订阅

developer.paypal.com/docs/subscr…

webhook

developer.paypal.com/api/rest/we…

image.png 这里主要展示了订阅计划的入口及api,具体的订阅计划类型以及订阅的货币、订阅期setupFee等如果你需要开发的话再结合文档和其他资料研究下

退款

create Order 退款 image.png 订阅退款 当收到订阅回调后,会返回一个ID,下面两种退款方式都可以,只是对应的api版本不同

image.png 退款成功后回调事件:PAYMENT.CAPTURE.REFUNDED


image.png 退款成功后回调事件:PAYMENT.SALE.REFUNDED

开发笔记

  1. 产品ID,创建时可以传自己公司内部的产品ID,形成对应关系,不传会自动生成;
  2. 调用CreateSubscription接口时,start_time不传会默认当前时间开始订阅;
  3. 调用create subscription接口时,可以覆写plan,所以可以根据每个用户进行定制化订阅,但是覆写的plan属性是少于原plan的,比如frequency只能根据原plan执行;
  4. 在paypal中订阅有暂停状态,当用户订阅因任何原因暂停时,将停止扣款,订阅恢复后,从暂停到恢复之间的费用不会扣款;
  5. 当paypal订阅扣款成功后,会收到 PAYMENT.SALE.COMPLETED 回调,当paypal定于扣款失败后,回调是:BILLING.SUBSCRIPTION.PAYMENT.FAILED ;
  6. 当用户余额不足时,未缴纳的费用在OUTSTANDING_BALANCE 中,当用户余额充足时,可以手动调用 /v1/billing/subscriptions/{id}/capture 接口扣除这部分费用,需要注意的是paypal自带的重试也是通过这个接口实现的,所以当你将订阅周期设置为天时,你调用这个接口会提示这笔费用已经被capture了;
  7. 固定周期费用可以将 payment_failure_threshold 设置大一些,比如我们生产中将其设置为120,我们的订阅周期是2年,用户订阅后,如果卡上没钱,剩下的23个月的费用都将在 OUTSTANDING_BALANCE 中,等用户卡上有钱了,可以告知我们然后我们触发capture接口一次扣除用户所有滞纳的费用;
  8. 调用capture接口成功后,你收到的回调是 PAYMENT.SALE.COMPLETED;
  9. 如果设置了一次性开户费,即setup_fee,如图,注意webhook会收到两次回调,一次是98.45,一次是19.69;
  10. 如果订阅扣费重试次数达到失败阈值,paypal暂停了订阅,此时如果想恢复订阅(调用active接口)是不可行的,会提示“Subscription cannot be actiivated after payment failure threshold has reached”,需要先capture捕获成功所欠的费用,然后再进行active

image.png 11. paypal回调时间 订阅时间:2024-12-12T15:46:19 订单回调时间:2025-04-12 10:46:30 订阅的回调可能早于一个周期时间,如果根据时间间隔计算周期数,可能会出现错误

Awx

订阅

在awx中首先需要创建customer,对于awx的订阅来说,customer是必须的;

image.png 创建完customer后,使用已创建的customerId,创建paymenIntent;

image.png

// 获取token
String token = airwallexTokenService.getToken();
AirwallexCustomerVO providerCustomer = null;
IJPayHttpResponse resData = AirwallexApi.retrieveCustomerList(airwallexConfig, oasisOrder.getUserId(), token);
log.info(" build payment subscription retrieve customer resp: {}", resData.toString());
if (resData.getStatus() == 201 || resData.getStatus() == 200) {
    TypeReference<AirwallexListVO<AirwallexCustomerVO>> typeReference = new TypeReference<AirwallexListVO<AirwallexCustomerVO>>() {
    };
    AirwallexListVO<AirwallexCustomerVO> airwallexList = JSONUtil.toBean(resData.getBody(), typeReference, true);
    if (!airwallexList.getItems().isEmpty()) {
        providerCustomer = airwallexList.getItems().get(0);
        log.info("fetch customer info: {}", providerCustomer);
    }
}
if (providerCustomer == null) {
    // create customer
    resData = AirwallexApi.createCustomer(airwallexConfig, token, buildCustomerRequestBody(oasisOrder));
    log.info(" build payment subscription create customer resp: {}", resData.toString());
    AssertUtil.assertTrue(resData.getStatus() != 201, "create customer fail");
    providerCustomer = JSONUtil.toBean(resData.getBody(), AirwallexCustomerVO.class);
}
if (StringUtils.isEmpty(providerCustomer.getClientSecret())) {
    resData = AirwallexApi.generateClientSecret(airwallexConfig, providerCustomer.getId(), token);
    log.info(" generate client secret resp: {}", resData.toString());
    AirwallexClientSecretVO clientSecretVO = JSONUtil.toBean(resData.getBody(), AirwallexClientSecretVO.class);
    providerCustomer.setClientSecret(clientSecretVO.getClientSecret());
}
resData = AirwallexApi.createOrder(airwallexConfig, buildPaymentIntentRequestBody(oasisOrder, providerCustomer.getId()), token);
log.info("build payment create payment intent resp: {}", resData.toString());
AssertUtil.assertTrue(resData.getStatus() != 201, "create payment intent fail");
PaymentIntentVO paymentIntent = JSONUtil.toBean(resData.getBody(), PaymentIntentVO.class);
if (oasisOrder.getId() != null) {
    oasisOrder.setPaymentId(paymentIntent.getId());
    orderRepository.save(oasisOrder);
}

image.png 将客户端需要的请求参数构建完成后传给客户端,之后等待用户验证通过,收到回调即可处理;

开发笔记

  1. 当用户刷新二维码,或者用户扫码后过了一段时间才付款,此时用户的cst_会发生改变,payment_consent.created事件会收到多个,所以如果要进行与用户绑定,要注意这个事件;
  2. 在awx中,用户签订cst协议后(收到payment_consent.verified),card类型会自动发起扣款,但是Alipay_cn需要我们执行发起扣款,wechat_pay我们未申请资质,暂时不太清楚; image.png