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…
开发过程中一定要注意版本,各个版本之间还是有兼容性问题的,如果遇到可以向awx技术支持人员反馈。
DOCS
www.airwallex.com/docs/paymen…
后台
hpp
hpp支付接入
为了和之前的系统风格保持一致,且为了兼容业务中的支付代码,最大可能代码复用,本次开发仿Jpay风格。以下是仿照的一些代码
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页面填入参数后浏览器打开即可支付
webhook
- 本地调试时可以考虑使用贝锐花生壳,内网穿透实现回调调用到本地
- 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…
这里主要展示了订阅计划的入口及api,具体的订阅计划类型以及订阅的货币、订阅期setupFee等如果你需要开发的话再结合文档和其他资料研究下
退款
create Order 退款
订阅退款
当收到订阅回调后,会返回一个ID,下面两种退款方式都可以,只是对应的api版本不同
退款成功后回调事件:PAYMENT.CAPTURE.REFUNDED
退款成功后回调事件:PAYMENT.SALE.REFUNDED
开发笔记
- 产品ID,创建时可以传自己公司内部的产品ID,形成对应关系,不传会自动生成;
- 调用CreateSubscription接口时,start_time不传会默认当前时间开始订阅;
- 调用create subscription接口时,可以覆写plan,所以可以根据每个用户进行定制化订阅,但是覆写的plan属性是少于原plan的,比如frequency只能根据原plan执行;
- 在paypal中订阅有暂停状态,当用户订阅因任何原因暂停时,将停止扣款,订阅恢复后,从暂停到恢复之间的费用不会扣款;
- 当paypal订阅扣款成功后,会收到 PAYMENT.SALE.COMPLETED 回调,当paypal定于扣款失败后,回调是:BILLING.SUBSCRIPTION.PAYMENT.FAILED ;
- 当用户余额不足时,未缴纳的费用在OUTSTANDING_BALANCE 中,当用户余额充足时,可以手动调用 /v1/billing/subscriptions/{id}/capture 接口扣除这部分费用,需要注意的是paypal自带的重试也是通过这个接口实现的,所以当你将订阅周期设置为天时,你调用这个接口会提示这笔费用已经被capture了;
- 固定周期费用可以将 payment_failure_threshold 设置大一些,比如我们生产中将其设置为120,我们的订阅周期是2年,用户订阅后,如果卡上没钱,剩下的23个月的费用都将在 OUTSTANDING_BALANCE 中,等用户卡上有钱了,可以告知我们然后我们触发capture接口一次扣除用户所有滞纳的费用;
- 调用capture接口成功后,你收到的回调是 PAYMENT.SALE.COMPLETED;
- 如果设置了一次性开户费,即setup_fee,如图,注意webhook会收到两次回调,一次是98.45,一次是19.69;
- 如果订阅扣费重试次数达到失败阈值,paypal暂停了订阅,此时如果想恢复订阅(调用active接口)是不可行的,会提示“Subscription cannot be actiivated after payment failure threshold has reached”,需要先capture捕获成功所欠的费用,然后再进行active
11. paypal回调时间
订阅时间:2024-12-12T15:46:19
订单回调时间:2025-04-12 10:46:30
订阅的回调可能早于一个周期时间,如果根据时间间隔计算周期数,可能会出现错误
Awx
订阅
在awx中首先需要创建customer,对于awx的订阅来说,customer是必须的;
创建完customer后,使用已创建的customerId,创建paymenIntent;
// 获取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);
}
将客户端需要的请求参数构建完成后传给客户端,之后等待用户验证通过,收到回调即可处理;
开发笔记
- 当用户刷新二维码,或者用户扫码后过了一段时间才付款,此时用户的cst_会发生改变,payment_consent.created事件会收到多个,所以如果要进行与用户绑定,要注意这个事件;
- 在awx中,用户签订cst协议后(收到payment_consent.verified),card类型会自动发起扣款,但是Alipay_cn需要我们执行发起扣款,wechat_pay我们未申请资质,暂时不太清楚;