|
|
@@ -20,14 +20,14 @@ import java.util.concurrent.TimeUnit;
|
|
|
|
|
|
/**
|
|
|
* 通过微信支付 v3 电子发票 API 将航信发票插入用户微信卡包。
|
|
|
- *
|
|
|
- * @see <a href="https://pay.wechatpay.cn/doc/v3/merchant/4012538365">插入卡包 API</a>
|
|
|
+ * 三步流程:创建申请单 → 上传PDF → 插入卡包
|
|
|
*/
|
|
|
@Service
|
|
|
@Slf4j
|
|
|
public class WechatPayFapiaoService {
|
|
|
|
|
|
private static final String API_BASE = "https://api.mch.weixin.qq.com";
|
|
|
+ private static final String CREATE_APP = "/v3/new-tax-control-fapiao/fapiao-applications";
|
|
|
private static final String UPLOAD_FILE = "/v3/new-tax-control-fapiao/fapiao-applications/%s/fapiao-files";
|
|
|
private static final String INSERT_CARDS = "/v3/new-tax-control-fapiao/fapiao-applications/%s/insert-cards";
|
|
|
private static final String MCH_SERIAL = "6A45EEB068369430B2FFD45EA29F641A8E18165F";
|
|
|
@@ -41,54 +41,37 @@ public class WechatPayFapiaoService {
|
|
|
}
|
|
|
|
|
|
private final OkHttpClient okHttpClient = new OkHttpClient.Builder()
|
|
|
- .connectTimeout(15, TimeUnit.SECONDS)
|
|
|
- .readTimeout(30, TimeUnit.SECONDS)
|
|
|
- .build();
|
|
|
-
|
|
|
- /**
|
|
|
- * 将已开具的航信电子发票插入用户微信卡包。
|
|
|
- * 流程:下载航信 PDF → 上传微信支付获取 media_id → 调用 insert-cards。
|
|
|
- *
|
|
|
- * @return true 表示调用成功
|
|
|
- */
|
|
|
+ .connectTimeout(15, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).build();
|
|
|
+
|
|
|
public boolean insertToCard(Invoice invoice, InvoiceInfo invoiceInfo) {
|
|
|
if (invoice.getOpenid() == null || invoice.getOpenid().isBlank()) {
|
|
|
log.info("用户无 openid,跳过卡包插入, applyId:{}", invoice.getApplyId());
|
|
|
return false;
|
|
|
}
|
|
|
-
|
|
|
try {
|
|
|
- // Step 1: 下载航信 PDF
|
|
|
+ // Step 1: 创建发票申请单
|
|
|
+ var appReq = buildCreateAppRequest(invoice, invoiceInfo);
|
|
|
+ log.info("创建发票申请单, applyId:{}, body:{}", invoice.getApplyId(), JSON.toJSONString(appReq));
|
|
|
+
|
|
|
+ HttpResponse<JSONObject> appResp = WxPayServiceImpl.wxHttpClient.post(
|
|
|
+ JSON_HEADERS, API_BASE + CREATE_APP,
|
|
|
+ new JsonRequestBody.Builder().body(JSON.toJSONString(appReq)).build(),
|
|
|
+ JSONObject.class);
|
|
|
+ log.info("创建申请单响应, applyId:{}, resp:{}",
|
|
|
+ invoice.getApplyId(), JSON.toJSONString(appResp.getServiceResponse()));
|
|
|
+
|
|
|
+ // Step 2: 下载航信PDF并上传到微信支付
|
|
|
byte[] pdfBytes = downloadPdf(invoiceInfo.getInvoiceurl());
|
|
|
if (pdfBytes == null || pdfBytes.length == 0) {
|
|
|
log.warn("下载航信 PDF 失败, applyId:{}", invoice.getApplyId());
|
|
|
return false;
|
|
|
}
|
|
|
-
|
|
|
- // Step 2: 上传 PDF 到微信支付
|
|
|
- String meta = JSON.toJSONString(Map.of(
|
|
|
- "file_type", "FAPIAO_PDF",
|
|
|
- "fapiao_apply_id", invoice.getApplyId()
|
|
|
- ));
|
|
|
-
|
|
|
- String uploadUrl = API_BASE + String.format(UPLOAD_FILE, invoice.getApplyId());
|
|
|
- log.info("上传发票文件, applyId:{}, meta:{}", invoice.getApplyId(), meta);
|
|
|
-
|
|
|
- String uploadRespBody = uploadFile(uploadUrl, meta, pdfBytes);
|
|
|
- if (uploadRespBody == null) {
|
|
|
+ String mediaId = uploadFile(invoice.getApplyId(), pdfBytes);
|
|
|
+ if (mediaId == null) {
|
|
|
log.warn("上传发票文件失败, applyId:{}", invoice.getApplyId());
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
- JSONObject uploadJson = JSONObject.parseObject(uploadRespBody);
|
|
|
- String mediaId = uploadJson.getString("fapiao_media_id");
|
|
|
- log.info("上传发票文件成功, applyId:{}, mediaId:{}", invoice.getApplyId(), mediaId);
|
|
|
-
|
|
|
- if (mediaId == null || mediaId.isBlank()) {
|
|
|
- log.warn("未获取到 fapiao_media_id, applyId:{}", invoice.getApplyId());
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
// Step 3: 插入卡包
|
|
|
var cardReq = buildInsertCardsRequest(invoice, invoiceInfo, mediaId);
|
|
|
String cardUrl = API_BASE + String.format(INSERT_CARDS, invoice.getApplyId());
|
|
|
@@ -98,10 +81,8 @@ public class WechatPayFapiaoService {
|
|
|
JSON_HEADERS, cardUrl,
|
|
|
new JsonRequestBody.Builder().body(JSON.toJSONString(cardReq)).build(),
|
|
|
JSONObject.class);
|
|
|
-
|
|
|
- log.info("插入发票卡包响应, applyId:{}, resp:{}",
|
|
|
+ log.info("插入卡包响应, applyId:{}, resp:{}",
|
|
|
invoice.getApplyId(), JSON.toJSONString(cardResp.getServiceResponse()));
|
|
|
-
|
|
|
return true;
|
|
|
} catch (Exception e) {
|
|
|
log.error("微信卡包插入异常, applyId:{}, msg:{}", invoice.getApplyId(), e.getMessage());
|
|
|
@@ -109,26 +90,78 @@ public class WechatPayFapiaoService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // ========== Step 1: 创建申请单 ==========
|
|
|
+
|
|
|
+ private HashMap<String, Object> buildCreateAppRequest(Invoice invoice, InvoiceInfo info) {
|
|
|
+ var orderDetails = invoice.getOrderDetails();
|
|
|
+ int elecMoney = orderDetails.stream().mapToInt(InvoiceOrderDetail::getElecMoney).sum();
|
|
|
+ int serviceMoney = orderDetails.stream().mapToInt(InvoiceOrderDetail::getServiceMoney).sum();
|
|
|
+ int discount = orderDetails.stream().mapToInt(InvoiceOrderDetail::getServiceMoneyDiscount).sum();
|
|
|
+ int netServiceMoney = serviceMoney - discount;
|
|
|
+
|
|
|
+ var items = new ArrayList<HashMap<String, Object>>();
|
|
|
+ int totalAmount = 0;
|
|
|
+ if (elecMoney > 0) { items.add(buildAppItem("充电电费", elecMoney, "1100101020200000000", 1300)); totalAmount += elecMoney; }
|
|
|
+ if (netServiceMoney > 0) { items.add(buildAppItem("充电服务费", netServiceMoney, "1100101020200000000", 1300)); totalAmount += netServiceMoney; }
|
|
|
+
|
|
|
+ var fapiaoInfo = new HashMap<String, Object>();
|
|
|
+ fapiaoInfo.put("fapiao_id", invoice.getApplyId());
|
|
|
+ fapiaoInfo.put("total_amount", totalAmount);
|
|
|
+ fapiaoInfo.put("fapiao_bill_type", "COMM_FAPIAO");
|
|
|
+ fapiaoInfo.put("billing_person_id", "2061377495014797313");
|
|
|
+ fapiaoInfo.put("billing_person", "*磊 2417");
|
|
|
+ fapiaoInfo.put("items", items);
|
|
|
+ if (invoice.getRemark() != null && !invoice.getRemark().isBlank()) {
|
|
|
+ fapiaoInfo.put("remark", invoice.getRemark());
|
|
|
+ }
|
|
|
+
|
|
|
+ var buyerInfo = new HashMap<String, Object>();
|
|
|
+ buyerInfo.put("type", invoice.getInvoiceType());
|
|
|
+ buyerInfo.put("name", invoice.getInvoiceTitle());
|
|
|
+ if (invoice.getTaxId() != null && !invoice.getTaxId().isBlank()) {
|
|
|
+ buyerInfo.put("taxpayer_id", invoice.getTaxId());
|
|
|
+ }
|
|
|
+
|
|
|
+ var req = new HashMap<String, Object>();
|
|
|
+ req.put("fapiao_apply_id", invoice.getApplyId());
|
|
|
+ req.put("scene", "WITHOUT_WECHATPAY");
|
|
|
+ req.put("buyer_information", buyerInfo);
|
|
|
+ req.put("fapiao_information", List.of(fapiaoInfo));
|
|
|
+
|
|
|
+ return req;
|
|
|
+ }
|
|
|
+
|
|
|
+ private HashMap<String, Object> buildAppItem(String name, int totalAmount, String taxCode, int taxRate) {
|
|
|
+ var item = new HashMap<String, Object>();
|
|
|
+ item.put("tax_code", taxCode);
|
|
|
+ item.put("goods_name", name);
|
|
|
+ item.put("quantity", 100000000);
|
|
|
+ item.put("total_amount", totalAmount);
|
|
|
+ item.put("tax_rate", taxRate);
|
|
|
+ item.put("discount", false);
|
|
|
+ item.put("preferential_policy_code", 1);
|
|
|
+ return item;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== Step 2: 下载PDF & 上传文件 ==========
|
|
|
+
|
|
|
private byte[] downloadPdf(String url) {
|
|
|
try {
|
|
|
var req = new Request.Builder().url(url).get().build();
|
|
|
try (var resp = okHttpClient.newCall(req).execute()) {
|
|
|
- if (resp.isSuccessful() && resp.body() != null) {
|
|
|
- return resp.body().bytes();
|
|
|
- }
|
|
|
+ if (resp.isSuccessful() && resp.body() != null) return resp.body().bytes();
|
|
|
}
|
|
|
- } catch (Exception e) {
|
|
|
- log.error("下载 PDF 异常, url:{}", url, e);
|
|
|
- }
|
|
|
+ } catch (Exception e) { log.error("下载 PDF 异常, url:{}", url, e); }
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 上传文件到微信支付,使用 SDK credential 签名 + OkHttp3 发送 multipart。
|
|
|
- */
|
|
|
- private String uploadFile(String url, String meta, byte[] pdfBytes) throws Exception {
|
|
|
- // 构建 multipart body
|
|
|
- String boundary = "WxPayUpload" + System.currentTimeMillis();
|
|
|
+ private String uploadFile(String applyId, byte[] pdfBytes) throws Exception {
|
|
|
+ String meta = JSON.toJSONString(Map.of(
|
|
|
+ "file_type", "FAPIAO_PDF",
|
|
|
+ "fapiao_apply_id", applyId
|
|
|
+ ));
|
|
|
+
|
|
|
+ String boundary = "WxPayUp" + System.currentTimeMillis();
|
|
|
okhttp3.RequestBody multipartBody = new MultipartBody.Builder(boundary)
|
|
|
.setType(MultipartBody.FORM)
|
|
|
.addFormDataPart("meta", null,
|
|
|
@@ -137,29 +170,31 @@ public class WechatPayFapiaoService {
|
|
|
okhttp3.RequestBody.create(pdfBytes, okhttp3.MediaType.parse("application/pdf")))
|
|
|
.build();
|
|
|
|
|
|
- // 用 SDK 生成签名 — 签名的 body 是 meta 字符串
|
|
|
+ String url = API_BASE + String.format(UPLOAD_FILE, applyId);
|
|
|
String auth = WxPayServiceImpl.config.createCredential()
|
|
|
.getAuthorization(new java.net.URI(url), "POST", meta);
|
|
|
|
|
|
- // 发送请求
|
|
|
okhttp3.Request request = new Request.Builder()
|
|
|
- .url(url)
|
|
|
- .header("Authorization", auth)
|
|
|
+ .url(url).header("Authorization", auth)
|
|
|
.header("Accept", "application/json")
|
|
|
.header("Wechatpay-Serial", MCH_SERIAL)
|
|
|
- .post(multipartBody)
|
|
|
- .build();
|
|
|
-
|
|
|
- try (okhttp3.Response response = okHttpClient.newCall(request).execute()) {
|
|
|
- if (response.isSuccessful() && response.body() != null) {
|
|
|
- return response.body().string();
|
|
|
+ .post(multipartBody).build();
|
|
|
+
|
|
|
+ try (okhttp3.Response resp = okHttpClient.newCall(request).execute()) {
|
|
|
+ if (resp.isSuccessful() && resp.body() != null) {
|
|
|
+ JSONObject json = JSONObject.parseObject(resp.body().string());
|
|
|
+ String mediaId = json.getString("fapiao_media_id");
|
|
|
+ log.info("上传发票文件成功, applyId:{}, mediaId:{}", applyId, mediaId);
|
|
|
+ return mediaId;
|
|
|
}
|
|
|
- String errBody = response.body() != null ? response.body().string() : "";
|
|
|
- log.error("上传文件 HTTP {}, body:{}", response.code(), errBody);
|
|
|
- return null;
|
|
|
+ String err = resp.body() != null ? resp.body().string() : "";
|
|
|
+ log.error("上传文件失败, applyId:{}, HTTP:{}, body:{}", applyId, resp.code(), err);
|
|
|
}
|
|
|
+ return null;
|
|
|
}
|
|
|
|
|
|
+ // ========== Step 3: 插入卡包 ==========
|
|
|
+
|
|
|
private HashMap<String, Object> buildInsertCardsRequest(Invoice invoice, InvoiceInfo info, String mediaId) {
|
|
|
var orderDetails = invoice.getOrderDetails();
|
|
|
int elecMoney = orderDetails.stream().mapToInt(InvoiceOrderDetail::getElecMoney).sum();
|
|
|
@@ -170,25 +205,11 @@ public class WechatPayFapiaoService {
|
|
|
var items = new ArrayList<HashMap<String, Object>>();
|
|
|
int totalAmount = 0;
|
|
|
int totalTax = 0;
|
|
|
+ if (elecMoney > 0) { int tax = elecMoney - (int)Math.round(elecMoney/1.13); items.add(buildItem("充电电费", elecMoney, "1100101020200000000", 1300)); totalAmount += elecMoney; totalTax += tax; }
|
|
|
+ if (netServiceMoney > 0) { int tax = netServiceMoney - (int)Math.round(netServiceMoney/1.13); items.add(buildItem("充电服务费", netServiceMoney, "1100101020200000000", 1300)); totalAmount += netServiceMoney; totalTax += tax; }
|
|
|
|
|
|
- if (elecMoney > 0) {
|
|
|
- int tax = elecMoney - (int) Math.round(elecMoney / 1.13);
|
|
|
- totalAmount += elecMoney;
|
|
|
- totalTax += tax;
|
|
|
- items.add(buildItem("充电电费", elecMoney, "1100101020200000000", 1300));
|
|
|
- }
|
|
|
- if (netServiceMoney > 0) {
|
|
|
- int tax = netServiceMoney - (int) Math.round(netServiceMoney / 1.13);
|
|
|
- totalAmount += netServiceMoney;
|
|
|
- totalTax += tax;
|
|
|
- items.add(buildItem("充电服务费", netServiceMoney, "1100101020200000000", 1300));
|
|
|
- }
|
|
|
-
|
|
|
- // 票面时间 → RFC 3339
|
|
|
String ticketDate = info.getTicketDate();
|
|
|
- if (ticketDate != null && ticketDate.length() == 16) {
|
|
|
- ticketDate += ":00";
|
|
|
- }
|
|
|
+ if (ticketDate != null && ticketDate.length() == 16) ticketDate += ":00";
|
|
|
String fapiaoTime = ticketDate != null
|
|
|
? LocalDateTime.parse(ticketDate, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
|
|
|
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss+08:00"))
|
|
|
@@ -198,8 +219,6 @@ public class WechatPayFapiaoService {
|
|
|
sellerInfo.put("name", "深圳市快与慢科技有限公司");
|
|
|
sellerInfo.put("taxpayer_id", "91440300MA5HJNDG14");
|
|
|
|
|
|
- var extraInfo = new HashMap<String, Object>();
|
|
|
-
|
|
|
var cardInfo = new HashMap<String, Object>();
|
|
|
cardInfo.put("fapiao_media_id", mediaId);
|
|
|
cardInfo.put("fapiao_number", info.getInvoicenumber());
|
|
|
@@ -209,24 +228,19 @@ public class WechatPayFapiaoService {
|
|
|
cardInfo.put("tax_amount", totalTax);
|
|
|
cardInfo.put("amount", totalAmount - totalTax);
|
|
|
cardInfo.put("seller_information", sellerInfo);
|
|
|
- cardInfo.put("extra_information", extraInfo);
|
|
|
+ cardInfo.put("extra_information", new HashMap<>());
|
|
|
cardInfo.put("items", items);
|
|
|
- if (invoice.getRemark() != null && !invoice.getRemark().isBlank()) {
|
|
|
- cardInfo.put("remark", invoice.getRemark());
|
|
|
- }
|
|
|
+ if (invoice.getRemark() != null && !invoice.getRemark().isBlank()) cardInfo.put("remark", invoice.getRemark());
|
|
|
|
|
|
var buyerInfo = new HashMap<String, Object>();
|
|
|
buyerInfo.put("type", invoice.getInvoiceType());
|
|
|
buyerInfo.put("name", invoice.getInvoiceTitle());
|
|
|
- if (invoice.getTaxId() != null && !invoice.getTaxId().isBlank()) {
|
|
|
- buyerInfo.put("taxpayer_id", invoice.getTaxId());
|
|
|
- }
|
|
|
+ if (invoice.getTaxId() != null && !invoice.getTaxId().isBlank()) buyerInfo.put("taxpayer_id", invoice.getTaxId());
|
|
|
|
|
|
var req = new HashMap<String, Object>();
|
|
|
req.put("scene", "WITHOUT_WECHATPAY");
|
|
|
req.put("buyer_information", buyerInfo);
|
|
|
req.put("fapiao_card_information", List.of(cardInfo));
|
|
|
-
|
|
|
return req;
|
|
|
}
|
|
|
|