|
@@ -4,29 +4,37 @@ import com.alibaba.fastjson2.JSON;
|
|
|
import com.alibaba.fastjson2.JSONObject;
|
|
import com.alibaba.fastjson2.JSONObject;
|
|
|
import com.kym.entity.miniapp.Invoice;
|
|
import com.kym.entity.miniapp.Invoice;
|
|
|
import com.kym.entity.wechat.InvoiceOrderDetail;
|
|
import com.kym.entity.wechat.InvoiceOrderDetail;
|
|
|
|
|
+import com.kym.huapiaoer.model.response.InvoiceInfo;
|
|
|
import com.wechat.pay.java.core.http.*;
|
|
import com.wechat.pay.java.core.http.*;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
+import okhttp3.OkHttpClient;
|
|
|
|
|
+import okhttp3.Request;
|
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
|
|
|
|
|
+import java.nio.file.Files;
|
|
|
|
|
+import java.nio.file.Path;
|
|
|
|
|
+import java.time.LocalDateTime;
|
|
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
import java.util.ArrayList;
|
|
import java.util.ArrayList;
|
|
|
import java.util.HashMap;
|
|
import java.util.HashMap;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 通过微信支付 v3 电子发票 API 将航信发票插入用户微信卡包。
|
|
* 通过微信支付 v3 电子发票 API 将航信发票插入用户微信卡包。
|
|
|
*
|
|
*
|
|
|
- * @see <a href="https://pay.wechatpay.cn/doc/v3/merchant/4012538301">创建发票申请单</a>
|
|
|
|
|
- * @see <a href="https://pay.wechatpay.cn/doc/v3/merchant/4012538365">插入卡包</a>
|
|
|
|
|
|
|
+ * @see <a href="https://pay.wechatpay.cn/doc/v3/merchant/4012538365">插入卡包 API</a>
|
|
|
*/
|
|
*/
|
|
|
@Service
|
|
@Service
|
|
|
@Slf4j
|
|
@Slf4j
|
|
|
public class WechatPayFapiaoService {
|
|
public class WechatPayFapiaoService {
|
|
|
|
|
|
|
|
private static final String API_BASE = "https://api.mch.weixin.qq.com";
|
|
private static final String API_BASE = "https://api.mch.weixin.qq.com";
|
|
|
- private static final String CREATE_APPLICATION = "/v3/new-tax-control-fapiao/fapiao-applications";
|
|
|
|
|
- private static final String INSERT_CARD = "/v3/new-tax-control-fapiao/fapiao-applications/%s/card";
|
|
|
|
|
- /** 商户证书序列号 */
|
|
|
|
|
|
|
+ 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";
|
|
private static final String MCH_SERIAL = "6A45EEB068369430B2FFD45EA29F641A8E18165F";
|
|
|
|
|
+ private static final String APP_ID = "wx93b0ef1be901bd19";
|
|
|
|
|
|
|
|
private static final HttpHeaders JSON_HEADERS;
|
|
private static final HttpHeaders JSON_HEADERS;
|
|
|
|
|
|
|
@@ -37,37 +45,63 @@ public class WechatPayFapiaoService {
|
|
|
JSON_HEADERS.addHeader("Wechatpay-Serial", MCH_SERIAL);
|
|
JSON_HEADERS.addHeader("Wechatpay-Serial", MCH_SERIAL);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private final OkHttpClient downloadClient = new OkHttpClient.Builder()
|
|
|
|
|
+ .connectTimeout(15, TimeUnit.SECONDS)
|
|
|
|
|
+ .readTimeout(30, TimeUnit.SECONDS)
|
|
|
|
|
+ .build();
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 将已开具的航信电子发票插入用户微信卡包。
|
|
* 将已开具的航信电子发票插入用户微信卡包。
|
|
|
- * 通过微信支付 v3 接口创建发票申请单后直接插卡。
|
|
|
|
|
|
|
+ * 流程:下载航信 PDF → 上传微信支付获取 media_id → 调用 insert-cards。
|
|
|
*
|
|
*
|
|
|
* @return true 表示调用成功
|
|
* @return true 表示调用成功
|
|
|
*/
|
|
*/
|
|
|
- public boolean insertToCard(Invoice invoice) {
|
|
|
|
|
|
|
+ public boolean insertToCard(Invoice invoice, InvoiceInfo invoiceInfo) {
|
|
|
if (invoice.getOpenid() == null || invoice.getOpenid().isBlank()) {
|
|
if (invoice.getOpenid() == null || invoice.getOpenid().isBlank()) {
|
|
|
log.info("用户无 openid,跳过卡包插入, applyId:{}", invoice.getApplyId());
|
|
log.info("用户无 openid,跳过卡包插入, applyId:{}", invoice.getApplyId());
|
|
|
return false;
|
|
return false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ Path tempFile = null;
|
|
|
try {
|
|
try {
|
|
|
- // Step 1: 创建发票申请单
|
|
|
|
|
- var appReq = buildApplicationRequest(invoice);
|
|
|
|
|
- log.info("创建微信发票申请单, applyId:{}, body:{}", invoice.getApplyId(), JSON.toJSONString(appReq));
|
|
|
|
|
-
|
|
|
|
|
- HttpResponse<JSONObject> appResp = WxPayServiceImpl.wxHttpClient.post(
|
|
|
|
|
- JSON_HEADERS, API_BASE + CREATE_APPLICATION,
|
|
|
|
|
- new JsonRequestBody.Builder().body(JSON.toJSONString(appReq)).build(),
|
|
|
|
|
- JSONObject.class);
|
|
|
|
|
-
|
|
|
|
|
- log.info("微信发票申请单响应, applyId:{}, resp:{}",
|
|
|
|
|
- invoice.getApplyId(), JSON.toJSONString(appResp.getServiceResponse()));
|
|
|
|
|
-
|
|
|
|
|
- // Step 2: 插入卡包
|
|
|
|
|
- var cardReq = new HashMap<String, Object>();
|
|
|
|
|
- cardReq.put("card_appid", "wx93b0ef1be901bd19");
|
|
|
|
|
- cardReq.put("card_openid", invoice.getOpenid());
|
|
|
|
|
-
|
|
|
|
|
- String cardUrl = API_BASE + String.format(INSERT_CARD, invoice.getApplyId());
|
|
|
|
|
|
|
+ // Step 1: 下载航信 PDF
|
|
|
|
|
+ byte[] pdfBytes = downloadPdf(invoiceInfo.getInvoiceurl());
|
|
|
|
|
+ if (pdfBytes == null || pdfBytes.length == 0) {
|
|
|
|
|
+ log.warn("下载航信 PDF 失败, applyId:{}", invoice.getApplyId());
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Step 2: 上传 PDF 到微信支付,获取 fapiao_media_id
|
|
|
|
|
+ tempFile = Files.createTempFile("fapiao_", ".pdf");
|
|
|
|
|
+ Files.write(tempFile, pdfBytes);
|
|
|
|
|
+
|
|
|
|
|
+ String meta = JSON.toJSONString(Map.of(
|
|
|
|
|
+ "file_type", "FAPIAO_PDF",
|
|
|
|
|
+ "fapiao_apply_id", invoice.getApplyId()
|
|
|
|
|
+ ));
|
|
|
|
|
+
|
|
|
|
|
+ HttpHeaders uploadHeaders = new HttpHeaders();
|
|
|
|
|
+ uploadHeaders.addHeader("Accept", "application/json");
|
|
|
|
|
+ uploadHeaders.addHeader("Wechatpay-Serial", MCH_SERIAL);
|
|
|
|
|
+
|
|
|
|
|
+ String uploadUrl = API_BASE + String.format(UPLOAD_FILE, invoice.getApplyId());
|
|
|
|
|
+ log.info("上传发票文件, applyId:{}, url:{}", invoice.getApplyId(), uploadUrl);
|
|
|
|
|
+
|
|
|
|
|
+ HttpResponse<JSONObject> uploadResp = WxPayServiceImpl.wxHttpClient.upload(
|
|
|
|
|
+ uploadHeaders, uploadUrl, meta, tempFile.toString(), JSONObject.class);
|
|
|
|
|
+
|
|
|
|
|
+ JSONObject uploadBody = uploadResp.getServiceResponse();
|
|
|
|
|
+ log.info("上传发票文件响应, applyId:{}, resp:{}", invoice.getApplyId(), JSON.toJSONString(uploadBody));
|
|
|
|
|
+
|
|
|
|
|
+ String mediaId = uploadBody.getString("fapiao_media_id");
|
|
|
|
|
+ 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());
|
|
|
log.info("插入发票卡包, applyId:{}, body:{}", invoice.getApplyId(), JSON.toJSONString(cardReq));
|
|
log.info("插入发票卡包, applyId:{}, body:{}", invoice.getApplyId(), JSON.toJSONString(cardReq));
|
|
|
|
|
|
|
|
HttpResponse<JSONObject> cardResp = WxPayServiceImpl.wxHttpClient.post(
|
|
HttpResponse<JSONObject> cardResp = WxPayServiceImpl.wxHttpClient.post(
|
|
@@ -75,17 +109,35 @@ public class WechatPayFapiaoService {
|
|
|
new JsonRequestBody.Builder().body(JSON.toJSONString(cardReq)).build(),
|
|
new JsonRequestBody.Builder().body(JSON.toJSONString(cardReq)).build(),
|
|
|
JSONObject.class);
|
|
JSONObject.class);
|
|
|
|
|
|
|
|
- log.info("插入发票卡包响应, applyId:{}, resp:{}",
|
|
|
|
|
- invoice.getApplyId(), JSON.toJSONString(cardResp.getServiceResponse()));
|
|
|
|
|
|
|
+ log.info("插入发票卡包响应, applyId:{}, httpCode:{}, resp:{}",
|
|
|
|
|
+ invoice.getApplyId(), cardResp.getHttpStatusCode(),
|
|
|
|
|
+ JSON.toJSONString(cardResp.getServiceResponse()));
|
|
|
|
|
|
|
|
return true;
|
|
return true;
|
|
|
} catch (Exception e) {
|
|
} catch (Exception e) {
|
|
|
log.error("微信卡包插入异常, applyId:{}, msg:{}", invoice.getApplyId(), e.getMessage());
|
|
log.error("微信卡包插入异常, applyId:{}, msg:{}", invoice.getApplyId(), e.getMessage());
|
|
|
return false;
|
|
return false;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (tempFile != null) {
|
|
|
|
|
+ try { Files.deleteIfExists(tempFile); } catch (Exception ignored) {}
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private HashMap<String, Object> buildApplicationRequest(Invoice invoice) {
|
|
|
|
|
|
|
+ private byte[] downloadPdf(String url) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ var req = new Request.Builder().url(url).get().build();
|
|
|
|
|
+ var resp = downloadClient.newCall(req).execute();
|
|
|
|
|
+ if (resp.isSuccessful() && resp.body() != null) {
|
|
|
|
|
+ return resp.body().bytes();
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("下载 PDF 异常, url:{}", url, e);
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private HashMap<String, Object> buildInsertCardsRequest(Invoice invoice, InvoiceInfo info, String mediaId) {
|
|
|
var orderDetails = invoice.getOrderDetails();
|
|
var orderDetails = invoice.getOrderDetails();
|
|
|
int elecMoney = orderDetails.stream().mapToInt(InvoiceOrderDetail::getElecMoney).sum();
|
|
int elecMoney = orderDetails.stream().mapToInt(InvoiceOrderDetail::getElecMoney).sum();
|
|
|
int serviceMoney = orderDetails.stream().mapToInt(InvoiceOrderDetail::getServiceMoney).sum();
|
|
int serviceMoney = orderDetails.stream().mapToInt(InvoiceOrderDetail::getServiceMoney).sum();
|
|
@@ -94,24 +146,65 @@ public class WechatPayFapiaoService {
|
|
|
|
|
|
|
|
var items = new ArrayList<HashMap<String, Object>>();
|
|
var items = new ArrayList<HashMap<String, Object>>();
|
|
|
int totalAmount = 0;
|
|
int totalAmount = 0;
|
|
|
|
|
+ int totalTax = 0;
|
|
|
|
|
|
|
|
if (elecMoney > 0) {
|
|
if (elecMoney > 0) {
|
|
|
- items.add(buildItem("充电电费", elecMoney, "1100101020200000000", 1300));
|
|
|
|
|
|
|
+ int tax = elecMoney - (int) Math.round(elecMoney / 1.13);
|
|
|
|
|
+ int amt = elecMoney - tax;
|
|
|
|
|
+ items.add(buildItem("充电电费", amt, tax, elecMoney, "1100101020200000000", 1300));
|
|
|
totalAmount += elecMoney;
|
|
totalAmount += elecMoney;
|
|
|
|
|
+ totalTax += tax;
|
|
|
}
|
|
}
|
|
|
if (netServiceMoney > 0) {
|
|
if (netServiceMoney > 0) {
|
|
|
- items.add(buildItem("充电服务费", netServiceMoney, "1100101020200000000", 1300));
|
|
|
|
|
|
|
+ int tax = netServiceMoney - (int) Math.round(netServiceMoney / 1.13);
|
|
|
|
|
+ int amt = netServiceMoney - tax;
|
|
|
|
|
+ items.add(buildItem("充电服务费", amt, tax, netServiceMoney, "1100101020200000000", 1300));
|
|
|
totalAmount += netServiceMoney;
|
|
totalAmount += netServiceMoney;
|
|
|
|
|
+ totalTax += tax;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- var fapiaoInfo = new HashMap<String, Object>();
|
|
|
|
|
- fapiaoInfo.put("fapiao_id", invoice.getApplyId());
|
|
|
|
|
- fapiaoInfo.put("total_amount", totalAmount);
|
|
|
|
|
- fapiaoInfo.put("items", items);
|
|
|
|
|
|
|
+ // 票面时间转 RFC 3339
|
|
|
|
|
+ String ticketDate = info.getTicketDate();
|
|
|
|
|
+ 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"))
|
|
|
|
|
+ : LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss+08:00"));
|
|
|
|
|
+
|
|
|
|
|
+ // 销售方信息
|
|
|
|
|
+ var sellerInfo = new HashMap<String, Object>();
|
|
|
|
|
+ sellerInfo.put("name", "深圳市快与慢科技有限公司");
|
|
|
|
|
+ sellerInfo.put("taxpayer_id", "91440300MA5HJNDG14");
|
|
|
|
|
+ sellerInfo.put("address", "");
|
|
|
|
|
+ sellerInfo.put("telephone", "");
|
|
|
|
|
+ sellerInfo.put("bank_name", "");
|
|
|
|
|
+ sellerInfo.put("bank_account", "");
|
|
|
|
|
+
|
|
|
|
|
+ // 附加信息
|
|
|
|
|
+ var extraInfo = new HashMap<String, Object>();
|
|
|
|
|
+ extraInfo.put("payee", "");
|
|
|
|
|
+ extraInfo.put("reviewer", "");
|
|
|
|
|
+ extraInfo.put("drawer", "");
|
|
|
|
|
+
|
|
|
|
|
+ // 卡券信息
|
|
|
|
|
+ var cardInfo = new HashMap<String, Object>();
|
|
|
|
|
+ cardInfo.put("fapiao_media_id", mediaId);
|
|
|
|
|
+ cardInfo.put("fapiao_number", info.getInvoicenumber());
|
|
|
|
|
+ cardInfo.put("fapiao_code", info.getInvoicecode() != null ? info.getInvoicecode() : "");
|
|
|
|
|
+ cardInfo.put("fapiao_time", fapiaoTime);
|
|
|
|
|
+ cardInfo.put("total_amount", totalAmount);
|
|
|
|
|
+ cardInfo.put("tax_amount", totalTax);
|
|
|
|
|
+ cardInfo.put("amount", totalAmount - totalTax);
|
|
|
|
|
+ cardInfo.put("seller_information", sellerInfo);
|
|
|
|
|
+ cardInfo.put("extra_information", extraInfo);
|
|
|
|
|
+ cardInfo.put("items", items);
|
|
|
if (invoice.getRemark() != null && !invoice.getRemark().isBlank()) {
|
|
if (invoice.getRemark() != null && !invoice.getRemark().isBlank()) {
|
|
|
- fapiaoInfo.put("remark", invoice.getRemark());
|
|
|
|
|
|
|
+ cardInfo.put("remark", invoice.getRemark());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // 购买方
|
|
|
var buyerInfo = new HashMap<String, Object>();
|
|
var buyerInfo = new HashMap<String, Object>();
|
|
|
buyerInfo.put("type", invoice.getInvoiceType());
|
|
buyerInfo.put("type", invoice.getInvoiceType());
|
|
|
buyerInfo.put("name", invoice.getInvoiceTitle());
|
|
buyerInfo.put("name", invoice.getInvoiceTitle());
|
|
@@ -120,22 +213,21 @@ public class WechatPayFapiaoService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
var req = new HashMap<String, Object>();
|
|
var req = new HashMap<String, Object>();
|
|
|
- req.put("fapiao_apply_id", invoice.getApplyId());
|
|
|
|
|
req.put("scene", "WITHOUT_WECHATPAY");
|
|
req.put("scene", "WITHOUT_WECHATPAY");
|
|
|
req.put("buyer_information", buyerInfo);
|
|
req.put("buyer_information", buyerInfo);
|
|
|
- req.put("fapiao_information", List.of(fapiaoInfo));
|
|
|
|
|
|
|
+ req.put("fapiao_card_information", List.of(cardInfo));
|
|
|
|
|
|
|
|
return req;
|
|
return req;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private HashMap<String, Object> buildItem(String name, int totalAmount, String taxCode, int taxRate) {
|
|
|
|
|
|
|
+ private HashMap<String, Object> buildItem(String name, int amount, int taxAmount, int totalAmount,
|
|
|
|
|
+ String taxCode, int taxRate) {
|
|
|
var item = new HashMap<String, Object>();
|
|
var item = new HashMap<String, Object>();
|
|
|
item.put("tax_code", taxCode);
|
|
item.put("tax_code", taxCode);
|
|
|
item.put("goods_name", name);
|
|
item.put("goods_name", name);
|
|
|
item.put("quantity", 100000000);
|
|
item.put("quantity", 100000000);
|
|
|
item.put("total_amount", totalAmount);
|
|
item.put("total_amount", totalAmount);
|
|
|
item.put("tax_rate", taxRate);
|
|
item.put("tax_rate", taxRate);
|
|
|
- item.put("tax_prefer_mark", "NO_FAVORABLE");
|
|
|
|
|
item.put("discount", false);
|
|
item.put("discount", false);
|
|
|
return item;
|
|
return item;
|
|
|
}
|
|
}
|