🆕 #1529 微信支付退款增加支持单品退款和对应查询的接口

This commit is contained in:
Binary Wang 2020-06-07 17:59:34 +08:00
parent 58b261753d
commit 92c0fd698b
10 changed files with 426 additions and 7 deletions

View File

@ -89,9 +89,8 @@ public class WxPayRefundQueryRequest extends BaseWxPayRequest {
&& StringUtils.isBlank(outRefundNo) && StringUtils.isBlank(refundId)) ||
(StringUtils.isNotBlank(transactionId) && StringUtils.isNotBlank(outTradeNo)
&& StringUtils.isNotBlank(outRefundNo) && StringUtils.isNotBlank(refundId))) {
throw new WxPayException("transaction_idout_trade_noout_refund_norefund_id 必须四选一");
throw new WxPayException("transactionIdoutRefundNotransactionIdrefundId 必须四选一");
}
}
@Override

View File

@ -4,8 +4,11 @@ import com.github.binarywang.wxpay.config.WxPayConfig;
import com.github.binarywang.wxpay.constant.WxPayConstants.RefundAccountSource;
import com.github.binarywang.wxpay.exception.WxPayException;
import com.thoughtworks.xstream.annotations.XStreamAlias;
import com.thoughtworks.xstream.annotations.XStreamConverter;
import lombok.*;
import lombok.experimental.Accessors;
import me.chanjar.weixin.common.annotation.Required;
import me.chanjar.weixin.common.util.xml.XStreamCDataConverter;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
@ -26,7 +29,10 @@ import java.util.Map;
@NoArgsConstructor
@AllArgsConstructor
@XStreamAlias("xml")
@Accessors(chain = true)
public class WxPayRefundRequest extends BaseWxPayRequest {
private static final long serialVersionUID = 522565152886671848L;
private static final String[] REFUND_ACCOUNT = new String[]{
RefundAccountSource.RECHARGE_FUNDS, RefundAccountSource.UNSETTLED_FUNDS};
@ -127,7 +133,6 @@ public class WxPayRefundRequest extends BaseWxPayRequest {
* 描述操作员帐号, 默认为商户号
* </pre>
*/
//@Required
@XStreamAlias("op_user_id")
private String opUserId;
/**
@ -172,6 +177,54 @@ public class WxPayRefundRequest extends BaseWxPayRequest {
@XStreamAlias("notify_url")
private String notifyUrl;
/**
* <pre>
* 字段名商品详情
* 变量名detail
* 类型
* 示例值String(6000)
* 退款包含的商品列表信息detail字段列表说明
*
* 字段名 变量名 必填 类型 示例值 描述
* 商品列表 goods_detail String 示例见下文 商品信息使用Json数组格式提交
* 商品列表goods_detail字段列表说明
*
* 字段名 变量名 必填 类型 示例值 描述
* 商品编码 goods_id String(32) 商品编码 由半角的大小写字母数字中划线下划线中的一种或几种组成
* 微信侧商品编码 wxpay_goods_id String(32) 1001 微信支付定义的统一商品编号没有可不传
* 商品名称 goods_name String(256) iPhone6s 16G 商品的实际名称
* 商品退款金额 refund_amount int 528800 商品退款金额
* 商品退货数量 refund_quantity int 1 单品的退款数量
* 商品单价 price int 528800 单位为如果商户有优惠需传输商户优惠后的单价(例如用户对一笔100元的订单使用了商场发的优惠券100-50则活动商品的单价应为原单价-50)
* detail字段值举例如下
*
* {
* "goods_detail": [
* {
* "goods_id": "商品编码",
* "wxpay_goods_id": "1001",
* "goods_name": "iPhone6s 16G",
* "refund_amount": 528800,
* "refund_quantity": 1,
* "price": 528800
* },
* {
* "goods_id": "商品编码",
* "wxpay_goods_id": "1001",
* "goods_name": "iPhone6s 16G",
* "refund_amount": 528800,
* "refund_quantity": 1,
* "price": 608800
* }
* ]
* }
* 描述退款包含的商品列表信息全额退款可不传必须按照规范上传JSON格式
* </pre>
*/
@XStreamAlias("detail")
@XStreamConverter(value = XStreamCDataConverter.class)
private String detail;
@Override
public void checkAndSign(WxPayConfig config) throws WxPayException {
if (StringUtils.isBlank(this.getOpUserId())) {

View File

@ -0,0 +1,117 @@
package com.github.binarywang.wxpay.bean.result;
import com.google.gson.annotations.SerializedName;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 营销详情 .
*
* @author <a href="https://github.com/binarywang">Binary Wang</a>
* @date 2020-06-07
*/
@Data
public class WxPayRefundPromotionDetail implements Serializable {
private static final long serialVersionUID = 2197712244944584263L;
/**
* 字段名券ID
* 变量名promotion_id
* 是否必填
* 类型String(32)
* 示例例109519
* 描述券或者立减优惠id
*/
@SerializedName("promotion_id")
private String promotionId;
/**
* 字段名优惠范围
* 变量名scope
* 是否必填
* 类型String(32)
* 示例例SINGLE
* 描述GLOBAL- 全场代金券SINGLE- 单品优惠
*/
@SerializedName("scope")
private String scope;
/**
* 字段名优惠类型
* 变量名type
* 是否必填
* 类型String(32)
* 示例例DISCOUNT
* 描述COUPON- 代金券需要走结算资金的充值型代金券,境外商户券币种与支付币种一致DISCOUNT- 优惠券不走结算资金的免充值型优惠券境外商户券币种与标价币种一致
*/
@SerializedName("type")
private String type;
/**
* 字段名代金券退款金额
* 变量名refund_amount
* 是否必填
* 类型Int
* 示例例100
* 描述代金券退款金额<=退款金额退款金额-代金券或立减优惠退款金额为现金说明详见代金券或立减优惠
*/
@SerializedName("refund_amount")
private Integer refundAmount;
/**
* 字段名商品列表
* 变量名goods_detail
* 是否必填
* 类型String
* 示例例见下文
* 描述商品信息使用Json格式
*/
@SerializedName("goods_detail")
private List<GoodDetail> goodsDetails;
@Data
public static class GoodDetail {
/**
* 字段名商品编码
* 变量名goods_id
* 是否必填
* 类型String(32)
* 示例值商品编码
* 描述由半角的大小写字母数字中划线下划线中的一种或几种组成
*/
@SerializedName("goods_id")
private String goodsId;
/**
* 字段名优惠退款金额
* 变量名refund_amount
* 是否必填
* 类型int
* 示例值528800
* 描述优惠退款金额
*/
@SerializedName("refund_amount")
private Integer refundAmount;
/**
* 字段名商品退货数量
* 变量名refund_quantity
* 是否必填
* 类型int
* 示例值1
* 描述单品的退货数量
*/
@SerializedName("refund_quantity")
private Integer refundQuantity;
/**
* 字段名商品单价
* 变量名price
* 是否必填
* 类型int
* 示例值528800
* 描述单位为如果商户有优惠需传输商户优惠后的单价(例如用户对一笔100元的订单使用了商场发的优惠券100-50则活动商品的单价应为原单价-50)
*/
@SerializedName("price")
private Integer price;
}
}

View File

@ -3,12 +3,17 @@ package com.github.binarywang.wxpay.bean.result;
import java.util.List;
import com.google.common.collect.Lists;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.google.gson.reflect.TypeToken;
import com.thoughtworks.xstream.annotations.XStreamAlias;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import me.chanjar.weixin.common.util.json.WxGsonBuilder;
import org.apache.commons.lang3.StringUtils;
import org.w3c.dom.Document;
/**
@ -130,6 +135,34 @@ public class WxPayRefundQueryResult extends BaseWxPayResult {
private List<RefundRecord> refundRecords;
/**
* 营销详情.
*/
@XStreamAlias("promotion_detail")
private String promotionDetailString;
private List<WxPayRefundPromotionDetail> promotionDetails;
/**
* 组装生成营销详情信息.
*/
public void composePromotionDetails() {
if (StringUtils.isEmpty(this.promotionDetailString)) {
return;
}
JsonElement tmpJsonElement = new JsonParser().parse(this.promotionDetailString);
final List<WxPayRefundPromotionDetail> promotionDetail = WxGsonBuilder.create()
.fromJson(tmpJsonElement.getAsJsonObject().get("promotion_detail"),
new TypeToken<List<WxPayRefundPromotionDetail>>() {
}.getType()
);
this.setPromotionDetails(promotionDetail);
}
/**
* 组装生成退款记录属性的内容.
*/

View File

@ -1,15 +1,20 @@
package com.github.binarywang.wxpay.bean.result;
import java.io.Serializable;
import java.util.List;
import com.google.common.collect.Lists;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.google.gson.reflect.TypeToken;
import com.thoughtworks.xstream.annotations.XStreamAlias;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import me.chanjar.weixin.common.util.json.WxGsonBuilder;
import org.apache.commons.lang3.StringUtils;
import org.w3c.dom.Document;
import java.io.Serializable;
import java.util.List;
/**
* <pre>
* 微信支付-申请退款返回结果.
@ -115,8 +120,35 @@ public class WxPayRefundResult extends BaseWxPayResult implements Serializable {
@XStreamAlias("coupon_refund_fee")
private Integer couponRefundFee;
/**
* 营销详情.
*/
@XStreamAlias("promotion_detail")
private String promotionDetailString;
private List<WxPayRefundPromotionDetail> promotionDetails;
private List<WxPayRefundCouponInfo> refundCoupons;
/**
* 组装生成营销详情信息.
*/
public void composePromotionDetails() {
if (StringUtils.isEmpty(this.promotionDetailString)) {
return;
}
JsonElement tmpJsonElement = new JsonParser().parse(this.promotionDetailString);
final List<WxPayRefundPromotionDetail> promotionDetail = WxGsonBuilder.create()
.fromJson(tmpJsonElement.getAsJsonObject().get("promotion_detail"),
new TypeToken<List<WxPayRefundPromotionDetail>>() {
}.getType()
);
this.setPromotionDetails(promotionDetail);
}
/**
* 组装生成退款代金券信息.
*/

View File

@ -256,6 +256,33 @@ public interface WxPayService {
*/
WxPayRefundResult refund(WxPayRefundRequest request) throws WxPayException;
/**
* <pre>
* 申请退款API支持单品.
* 详见 https://pay.weixin.qq.com/wiki/doc/api/danpin.php?chapter=9_103&index=3
*
* 应用场景
* 当交易发生之后一段时间内由于买家或者卖家的原因需要退款时卖家可以通过退款接口将支付款退还给买家微信支付将在收到退款请求并且验证成功之后按照退款规则将支付款按原路退到买家帐号上
*
* 注意
* 1交易时间超过一年的订单无法提交退款
* 2微信支付退款支持单笔交易分多次退款多次退款需要提交原支付订单的商户订单号和设置不同的退款单号申请退款总金额不能超过订单金额 一笔退款失败后重新提交请不要更换退款单号请使用原商户退款单号
* 3请求频率限制150qps即每秒钟正常的申请退款请求次数不超过150次
* 错误或无效请求频率限制6qps即每秒钟异常或错误的退款申请请求不超过6次
* 4每个支付订单的部分退款次数不能超过50次
* 5本接口支持单品优惠订单全额退款和单品优惠订单部分退款推荐使用本接口如果使用不支持单品优惠部分退款的历史接口请看https://pay.weixin.qq.com/wiki/doc/api/jsapi_sl.php?chapter=9_4
*
* 接口地址
* https://api.mch.weixin.qq.com/secapi/pay/refundv2
* https://api2.mch.weixin.qq.com/secapi/pay/refundv2(备用域名)见跨城冗灾方案
* </pre>
*
* @param request 请求对象
* @return 退款操作结果 wx pay refund result
* @throws WxPayException the wx pay exception
*/
WxPayRefundResult refundV2(WxPayRefundRequest request) throws WxPayException;
/**
* <pre>
* 微信支付-查询退款.
@ -293,6 +320,29 @@ public interface WxPayService {
*/
WxPayRefundQueryResult refundQuery(WxPayRefundQueryRequest request) throws WxPayException;
/**
* <pre>
* 微信支付-查询退款API支持单品.
* 应用场景
* 提交退款申请后通过调用该接口查询退款状态退款有一定延时用零钱支付的退款20分钟内到账银行卡支付的退款3个工作日后重新查询退款状态
* 注意
* 1本接口支持查询单品优惠相关退款信息且仅支持按微信退款单号或商户退款单号查询若继续调用老查询退款接口
* 请见https://pay.weixin.qq.com/wiki/doc/api/jsapi_sl.php?chapter=9_5
* 2请求频率限制300qps即每秒钟正常的退款查询请求次数不超过300次
* 3错误或无效请求频率限制6qps即每秒钟异常或错误的退款查询请求不超过6次
*
* 接口地址
* https://api.mch.weixin.qq.com/pay/refundqueryv2
* https://api2.mch.weixin.qq.com/pay/refundqueryv2(备用域名)见跨城冗灾方案
* 详见 https://pay.weixin.qq.com/wiki/doc/api/danpin.php?chapter=9_104&index=4
* </pre>
*
* @param request 微信退款单号
* @return 退款信息 wx pay refund query result
* @throws WxPayException the wx pay exception
*/
WxPayRefundQueryResult refundQueryV2(WxPayRefundQueryRequest request) throws WxPayException;
/**
* 解析支付结果通知.
* 详见https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_7

View File

@ -128,6 +128,22 @@ public abstract class BaseWxPayServiceImpl implements WxPayService {
return result;
}
@Override
public WxPayRefundResult refundV2(WxPayRefundRequest request) throws WxPayException {
request.checkAndSign(this.getConfig());
String url = this.getPayBaseUrl() + "/secapi/pay/refundv2";
if (this.getConfig().isUseSandboxEnv()) {
url = this.getConfig().getPayBaseUrl() + "/sandboxnew/pay/refundv2";
}
String responseContent = this.post(url, request.toXML(), true);
WxPayRefundResult result = BaseWxPayResult.fromXML(responseContent, WxPayRefundResult.class);
result.composePromotionDetails();
result.checkResult(this, request.getSignType(), true);
return result;
}
@Override
public WxPayRefundQueryResult refundQuery(String transactionId, String outTradeNo, String outRefundNo, String refundId)
throws WxPayException {
@ -152,6 +168,18 @@ public abstract class BaseWxPayServiceImpl implements WxPayService {
return result;
}
@Override
public WxPayRefundQueryResult refundQueryV2(WxPayRefundQueryRequest request) throws WxPayException {
request.checkAndSign(this.getConfig());
String url = this.getPayBaseUrl() + "/pay/refundqueryv2";
String responseContent = this.post(url, request.toXML(), false);
WxPayRefundQueryResult result = BaseWxPayResult.fromXML(responseContent, WxPayRefundQueryResult.class);
result.composePromotionDetails();
result.checkResult(this, request.getSignType(), true);
return result;
}
@Override
public WxPayOrderNotifyResult parseOrderNotifyResult(String xmlData) throws WxPayException {
return this.parseOrderNotifyResult(xmlData, null);

View File

@ -0,0 +1,47 @@
package com.github.binarywang.wxpay.bean.request;
import org.testng.annotations.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author <a href="https://github.com/binarywang">Binary Wang</a>
* @date 2020-06-07
*/
public class WxPayRefundRequestTest {
@Test
public void testToXML() {
WxPayRefundRequest refundRequest = new WxPayRefundRequest();
refundRequest.setAppid("wx2421b1c4370ec43b");
refundRequest.setMchId("10000100");
refundRequest.setNonceStr("6cefdb308e1e2e8aabd48cf79e546a02");
refundRequest.setNotifyUrl("https://weixin.qq.com/");
refundRequest.setOutRefundNo("1415701182");
refundRequest.setOutTradeNo("1415757673");
refundRequest.setRefundFee(1);
refundRequest.setTotalFee(1);
refundRequest.setTransactionId("");
refundRequest.setDetail("{\"goods_detail\":[{\"goods_id\":\"商品编码\",\"wxpay_goods_id\":\"1001\",\"goods_name\":\"iPhone6s\n" +
"16G\",\"refund_amount\":528800,\"refund_quantity\":1,\"price\":528800},{\"goods_id\":\"商品编码\",\"wxpay_goods_id\":\"1001\",\"goods_name\":\"iPhone6s\n" +
"16G\",\"refund_amount\"\":528800,\"refund_quantity\":1,\"price\":608800}]}");
refundRequest.setSign("FE56DD4AA85C0EECA82C35595A69E153");
assertThat(refundRequest.toXML())
.isEqualTo("<xml>\n" +
" <appid>wx2421b1c4370ec43b</appid>\n" +
" <mch_id>10000100</mch_id>\n" +
" <nonce_str>6cefdb308e1e2e8aabd48cf79e546a02</nonce_str>\n" +
" <sign>FE56DD4AA85C0EECA82C35595A69E153</sign>\n" +
" <transaction_id></transaction_id>\n" +
" <out_trade_no>1415757673</out_trade_no>\n" +
" <out_refund_no>1415701182</out_refund_no>\n" +
" <total_fee>1</total_fee>\n" +
" <refund_fee>1</refund_fee>\n" +
" <notify_url>https://weixin.qq.com/</notify_url>\n" +
" <detail><![CDATA[{\"goods_detail\":[{\"goods_id\":\"商品编码\",\"wxpay_goods_id\":\"1001\",\"goods_name\":\"iPhone6s\n" +
"16G\",\"refund_amount\":528800,\"refund_quantity\":1,\"price\":528800},{\"goods_id\":\"商品编码\",\"wxpay_goods_id\":\"1001\",\"goods_name\":\"iPhone6s\n" +
"16G\",\"refund_amount\"\":528800,\"refund_quantity\":1,\"price\":608800}]}]]></detail>\n" +
"</xml>");
}
}

View File

@ -13,7 +13,6 @@ import static org.assertj.core.api.Assertions.assertThat;
* @author <a href="https://github.com/binarywang">Binary Wang</a>
*/
public class WxPayRefundResultTest {
@Test
public void testFromXML() {
/*
@ -49,6 +48,49 @@ public class WxPayRefundResultTest {
assertThat(result.getRefundCoupons().get(0).getCouponRefundFee()).isEqualTo(1);
}
@Test
public void testFromXML_danpin() {
//样例来自https://pay.weixin.qq.com/wiki/doc/api/danpin.php?chapter=9_103&index=3
String xmlString = "<xml>\n" +
"<return_code><![CDATA[SUCCESS]]></return_code>\n" +
"<return_msg><![CDATA[OK]]></return_msg>\n" +
"<appid><![CDATA[wx2421b1c4370ec43b]]></appid>\n" +
"<mch_id><![CDATA[10000100]]></mch_id>\n" +
"<nonce_str><![CDATA[NfsMFbUFpdbEhPXP]]></nonce_str>\n" +
"<sign><![CDATA[B7274EB9F8925EB93100DD2085FA56C0]]></sign>\n" +
"<result_code><![CDATA[SUCCESS]]></result_code>\n" +
"<transaction_id><![CDATA[1008450740201411110005820873]]></transaction_id>\n" +
"<out_trade_no><![CDATA[1415757673]]></out_trade_no>\n" +
"<out_refund_no><![CDATA[1415701182]]></out_refund_no>\n" +
"<refund_id><![CDATA[2008450740201411110000174436]]></refund_id>\n" +
"<refund_channel><![CDATA[]]></refund_channel>\n" +
"<total_fee>1</total_fee >\n" +
"<refund_fee>1</refund_fee>\n" +
"<cash_fee>1</cash_fee >\n" +
"<cash_refund_fee>1</cash_refund_fee>\n" +
"<promotion_detail>{\"promotion_detail\":[{\"promotion_id\":\"109519\",\"scope\":\"SINGLE\",\"type\":\"DISCOUNT\",\"refund_amount\":5,\"goods_detail\":[{\"goods_id\":\"a_goods1\",\"refund_quantity\":7,\"price\":1,\"refund_amount\":4},{\"goods_id\":\"a_goods2\",\"refund_quantity\":1,\"price\":2,\"refund_amount\":1}]}]}</promotion_detail>\n" +
"</xml>";
WxPayRefundResult result = BaseWxPayResult.fromXML(xmlString, WxPayRefundResult.class);
result.composePromotionDetails();
assertThat(result.getPromotionDetails()).isNotEmpty();
assertThat(result.getPromotionDetails().get(0).getPromotionId()).isEqualTo("109519");
assertThat(result.getPromotionDetails().get(0).getRefundAmount()).isEqualTo(5);
assertThat(result.getPromotionDetails().get(0).getScope()).isEqualTo("SINGLE");
assertThat(result.getPromotionDetails().get(0).getType()).isEqualTo("DISCOUNT");
assertThat(result.getPromotionDetails().get(0).getGoodsDetails()).isNotEmpty();
assertThat(result.getPromotionDetails().get(0).getGoodsDetails().get(0).getGoodsId()).isEqualTo("a_goods1");
assertThat(result.getPromotionDetails().get(0).getGoodsDetails().get(0).getRefundQuantity()).isEqualTo(7);
assertThat(result.getPromotionDetails().get(0).getGoodsDetails().get(0).getRefundAmount()).isEqualTo(4);
assertThat(result.getPromotionDetails().get(0).getGoodsDetails().get(0).getPrice()).isEqualTo(1);
assertThat(result.getPromotionDetails().get(0).getGoodsDetails().get(1).getGoodsId()).isEqualTo("a_goods2");
assertThat(result.getPromotionDetails().get(0).getGoodsDetails().get(1).getRefundQuantity()).isEqualTo(1);
assertThat(result.getPromotionDetails().get(0).getGoodsDetails().get(1).getRefundAmount()).isEqualTo(1);
assertThat(result.getPromotionDetails().get(0).getGoodsDetails().get(1).getPrice()).isEqualTo(2);
}
@Test
public void testFromXMLFastMode() {
/*

View File

@ -315,6 +315,18 @@ public class BaseWxPayServiceImplTest {
log.info(result.toString());
}
@Test
public void testRefundV2() throws WxPayException {
WxPayRefundResult result = this.payService.refundV2(
WxPayRefundRequest.newBuilder()
.outRefundNo("aaa")
.outTradeNo("1111")
.totalFee(1222)
.refundFee(111)
.build());
log.info(result.toString());
}
/**
* Test method for {@link WxPayService#refundQuery(String, String, String, String)} .
*
@ -341,6 +353,11 @@ public class BaseWxPayServiceImplTest {
log.info(result.toString());
}
@Test
public void testRefundQueryV2() throws WxPayException {
this.payService.refundQueryV2(WxPayRefundQueryRequest.newBuilder().outRefundNo("1").build());
}
/**
* Test parse refund notify result.
*
@ -686,4 +703,5 @@ public class BaseWxPayServiceImplTest {
assertThat(result).isNotNull();
System.out.println(result);
}
}