diff --git a/docs/ELECTRONIC_INVOICE_API_COMPARISON.md b/docs/ELECTRONIC_INVOICE_API_COMPARISON.md index 54568878f..aed15d321 100644 --- a/docs/ELECTRONIC_INVOICE_API_COMPARISON.md +++ b/docs/ELECTRONIC_INVOICE_API_COMPARISON.md @@ -62,5 +62,5 @@ V3 服务商电子发票产品文档:前者是 API 列表中的具体接口, | 身份/鉴权上下文 | 公众号 access token、用户授权页/授权数据 | 微信支付服务商号、子商户号、V3 签名与敏感字段加密 | | 覆盖范围 | 授权、开票、冲红、查询及公众号商户配置 | 子商户邀约/状态、模板/开发配置、行业开票、文件和卡包、通知 | -因此,两个 Issue 都是同一个微信支付 V3 服务商电子发票接入需求,当前代码库检索未 -发现对应的 `new-tax-control-fapiao` API 实现。 +因此,两个 Issue 都是同一个微信支付 V3 服务商电子发票接入需求,当前由 +`weixin-java-pay` 的 `PartnerInvoiceService` 提供对应的 `new-tax-control-fapiao` API 实现。 diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/invoice/PassengerTransportInvoiceRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/invoice/PassengerTransportInvoiceRequest.java new file mode 100644 index 000000000..4898773dd --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/invoice/PassengerTransportInvoiceRequest.java @@ -0,0 +1,364 @@ +package com.github.binarywang.wxpay.bean.invoice; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * 服务商开具旅客运输行业电子发票请求。 + * + *
购买方手机号、邮箱以及出行人证件号码由调用方按微信支付文档加密。
+ * + * @see 开具旅客运输行业电子发票 + */ +@Data +public class PassengerTransportInvoiceRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 子商户号。微信支付分配的子商户号,必填,最长32个字符。 + */ + @SerializedName("sub_mchid") + private String subMchid; + + /** + * 发票申请单号。唯一标识一次开票行为并关联唯一的购买方信息,必填,最长32个字符; + * 微信支付账单开票场景下填写微信支付交易单号。 + */ + @SerializedName("fapiao_apply_id") + private String fapiaoApplyId; + + /** + * 购买方信息,即发票抬头,必填。 + */ + @SerializedName("buyer_information") + private BuyerInformation buyerInformation; + + /** + * 需要开具的旅客运输行业数电发票信息,必填。 + */ + @SerializedName("fapiao_information") + private FapiaoInformation fapiaoInformation; + + /** + * 购买方信息,即发票抬头。 + */ + @Data + public static class BuyerInformation implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 购买方类型,必填。可选值:INDIVIDUAL(个人、其他)、ORGANIZATION(单位)。 + */ + private String type; + + /** + * 购买方名称,必填,最长256个字符。 + */ + private String name; + + /** + * 购买方纳税人识别号,最长32个字符;购买方类型为 ORGANIZATION 时必填。 + */ + @SerializedName("taxpayer_id") + private String taxpayerId; + + /** + * 购买方地址,最长128个字符。 + */ + private String address; + + /** + * 购买方电话,最长32个字符。 + */ + private String telephone; + + /** + * 购买方开户银行名称,最长128个字符。 + */ + @SerializedName("bank_name") + private String bankName; + + /** + * 购买方银行账号,最长32个字符。 + */ + @SerializedName("bank_account") + private String bankAccount; + + /** + * 用户手机号。该字段为密文字段,调用方需使用微信支付公钥或微信支付平台证书公钥加密后传入。 + */ + private String phone; + + /** + * 用户邮箱地址。该字段为密文字段,调用方需使用微信支付公钥或微信支付平台证书公钥加密后传入。 + */ + private String email; + + /** + * 微信支付订单金额,仅在微信支付场景下有效,单位:分。 + */ + private Long amount; + + /** + * 商户订单号,仅在微信支付场景下有效,最长32个字符。 + */ + @SerializedName("out_trade_no") + private String outTradeNo; + + /** + * 用户选择开具的发票类型。可选值:COMM_FAPIAO(增值税普通发票)、VAT_FAPIAO(增值税专用发票)。 + */ + @SerializedName("fapiao_bill_type") + private String fapiaoBillType; + + /** + * 用户申请开票时提交的留言信息,最长100个字符。 + */ + @SerializedName("user_apply_message") + private String userApplyMessage; + } + + /** + * 需要开具的旅客运输行业数电发票信息。 + */ + @Data + public static class FapiaoInformation implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 商户发票单号,必填,最长32个字符,在每个商户下必须唯一。 + * 仅支持字母、数字、中划线、下划线、竖线和星号;开票失败或发票已冲红时可更换后重试, + * 同一发票申请单最多支持5个商户发票单号。 + */ + @SerializedName("fapiao_id") + private String fapiaoId; + + /** + * 总价税合计,必填,单位:分。值为所有发票行单行金额合计之和,且全部发票的总价税合计 + * 不能超过交易总金额。 + */ + @SerializedName("total_amount") + private Long totalAmount; + + /** + * 发票行信息,必填;单张发票最多包含8行。 + */ + private List接口受理成功时返回 HTTP 202 Accepted,无应答包体。受理成功不代表开票完成, + * 请通过开票结果回调或查询电子发票接口获取处理结果。
+ * + * @param request 开票申请 + * @throws WxPayException 微信支付异常 + * @see 官方文档 + */ + void issuePassengerTransportInvoice(PassengerTransportInvoiceRequest request) throws WxPayException; + /** * 查询电子发票。 * diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/PartnerInvoiceServiceImpl.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/PartnerInvoiceServiceImpl.java index af173851f..c51f8006e 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/PartnerInvoiceServiceImpl.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/PartnerInvoiceServiceImpl.java @@ -3,6 +3,7 @@ import com.github.binarywang.wxpay.bean.invoice.InviteUrlResult; import com.github.binarywang.wxpay.bean.invoice.InviteUrlRequest; import com.github.binarywang.wxpay.bean.invoice.GeneralInvoiceRequest; +import com.github.binarywang.wxpay.bean.invoice.PassengerTransportInvoiceRequest; import com.github.binarywang.wxpay.bean.invoice.InvoiceResult; import com.github.binarywang.wxpay.bean.invoice.InvoiceFileResult; import com.github.binarywang.wxpay.bean.invoice.ReverseInvoiceRequest; @@ -45,6 +46,7 @@ public class PartnerInvoiceServiceImpl implements PartnerInvoiceService { private static final com.google.gson.Gson GSON = WxGsonBuilder.create(); private static final String INVITE_URL_PATH = "/v3/new-tax-control-fapiao/fapiaomerchant/getspinviteurl"; private static final String ISSUE_GENERAL_PATH = "/v3/new-tax-control-fapiao/fapiao-applications/issue-general"; + private static final String ISSUE_PASSENGER_TRANSPORT_PATH = "/v3/new-tax-control-fapiao/fapiao-applications/issue-passenger-transport"; private static final String FAPIAO_APPLICATIONS_PATH = "/v3/new-tax-control-fapiao/fapiao-applications/"; private final WxPayService payService; @@ -80,6 +82,12 @@ public void issueGeneralInvoice(GeneralInvoiceRequest request) throws WxPayExcep this.payService.postV3(url, GSON.toJson(request)); } + @Override + public void issuePassengerTransportInvoice(PassengerTransportInvoiceRequest request) throws WxPayException { + String url = this.payService.getPayBaseUrl() + ISSUE_PASSENGER_TRANSPORT_PATH; + this.payService.postV3(url, GSON.toJson(request)); + } + @Override public InvoiceResult getInvoice(String fapiaoApplyId, String subMchId, String fapiaoId) throws WxPayException { String url = this.payService.getPayBaseUrl() + FAPIAO_APPLICATIONS_PATH + encode(fapiaoApplyId) + "?sub_mchid=" + encode(subMchId); diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/bean/invoice/PassengerTransportInvoiceRequestTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/bean/invoice/PassengerTransportInvoiceRequestTest.java new file mode 100644 index 000000000..eacbbef31 --- /dev/null +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/bean/invoice/PassengerTransportInvoiceRequestTest.java @@ -0,0 +1,94 @@ +package com.github.binarywang.wxpay.bean.invoice; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import org.testng.annotations.Test; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link PassengerTransportInvoiceRequest} 单元测试。 + */ +public class PassengerTransportInvoiceRequestTest { + + @Test + public void shouldSerializePassengerTransportInvoiceRequestUsingWechatPayFieldNames() { + PassengerTransportInvoiceRequest request = new PassengerTransportInvoiceRequest(); + request.setSubMchid("1900000109"); + request.setFapiaoApplyId("apply_20260827_001"); + + PassengerTransportInvoiceRequest.BuyerInformation buyer = + new PassengerTransportInvoiceRequest.BuyerInformation(); + buyer.setType("INDIVIDUAL"); + buyer.setName("示例旅客"); + buyer.setPhone("encrypted-phone"); + buyer.setEmail("encrypted-email"); + buyer.setAmount(1000L); + request.setBuyerInformation(buyer); + + PassengerTransportInvoiceRequest.PassengerInformation passenger = + new PassengerTransportInvoiceRequest.PassengerInformation(); + passenger.setName("张**"); + passenger.setCertificateType("IDENTITY_CARD"); + passenger.setCertificateNumber("encrypted-certificate-number"); + passenger.setDepartureDate("2026-08-27T10:00:00+08:00"); + passenger.setDeparturePlace("重庆市渝北区示例出发地"); + passenger.setDestination("重庆市两江新区示例目的地"); + passenger.setTransportationType("SHIP"); + passenger.setTransportationClasses("SHIP_FIRST_CLASS_CABIN"); + + PassengerTransportInvoiceRequest.InvoiceItem item = new PassengerTransportInvoiceRequest.InvoiceItem(); + item.setTaxCode("3010101020100000000"); + item.setGoodsName("旅客运输服务"); + item.setQuantity(2200000000L); + item.setTotalAmount(1000L); + item.setTaxRate(300L); + item.setDiscount(false); + item.setPassengerInformation(passenger); + + PassengerTransportInvoiceRequest.TransactionInformation transaction = + new PassengerTransportInvoiceRequest.TransactionInformation(); + transaction.setPayChannel("WECHAT_PAY"); + transaction.setOutTradeNo("order_20260827_001"); + transaction.setAmount(1000L); + + PassengerTransportInvoiceRequest.FapiaoInformation fapiao = + new PassengerTransportInvoiceRequest.FapiaoInformation(); + fapiao.setFapiaoId("invoice_20260827_001"); + fapiao.setTotalAmount(1000L); + fapiao.setItems(Collections.singletonList(item)); + fapiao.setBillingPersonId("billing_person_001"); + fapiao.setFapiaoBillType("COMM_FAPIAO"); + fapiao.setTransactionInformation(Collections.singletonList(transaction)); + request.setFapiaoInformation(fapiao); + + JsonObject json = new Gson().toJsonTree(request).getAsJsonObject(); + JsonObject passengerJson = json.getAsJsonObject("fapiao_information") + .getAsJsonArray("items").get(0).getAsJsonObject() + .getAsJsonObject("passenger_information"); + + assertThat(json.get("sub_mchid").getAsString()).isEqualTo("1900000109"); + assertThat(json.get("fapiao_apply_id").getAsString()).isEqualTo("apply_20260827_001"); + assertThat(json.getAsJsonObject("buyer_information").get("phone").getAsString()) + .isEqualTo("encrypted-phone"); + assertThat(json.getAsJsonObject("fapiao_information").getAsJsonArray("items").get(0) + .getAsJsonObject().get("quantity").getAsLong()).isEqualTo(2200000000L); + assertThat(passengerJson.get("name").getAsString()).isEqualTo("张**"); + assertThat(passengerJson.get("certificate_type").getAsString()).isEqualTo("IDENTITY_CARD"); + assertThat(passengerJson.get("certificate_number").getAsString()) + .isEqualTo("encrypted-certificate-number"); + assertThat(passengerJson.get("departure_date").getAsString()) + .isEqualTo("2026-08-27T10:00:00+08:00"); + assertThat(passengerJson.get("departure_place").getAsString()) + .isEqualTo("重庆市渝北区示例出发地"); + assertThat(passengerJson.get("destination").getAsString()) + .isEqualTo("重庆市两江新区示例目的地"); + assertThat(passengerJson.get("transportation_type").getAsString()).isEqualTo("SHIP"); + assertThat(passengerJson.get("transportation_classes").getAsString()) + .isEqualTo("SHIP_FIRST_CLASS_CABIN"); + assertThat(json.getAsJsonObject("fapiao_information").getAsJsonArray("transaction_information").get(0) + .getAsJsonObject().get("out_trade_no").getAsString()).isEqualTo("order_20260827_001"); + } +} diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PartnerInvoiceServiceImplTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PartnerInvoiceServiceImplTest.java index f0aa2d16a..342667225 100644 --- a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PartnerInvoiceServiceImplTest.java +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/PartnerInvoiceServiceImplTest.java @@ -2,6 +2,7 @@ import com.github.binarywang.wxpay.service.WxPayService; import com.github.binarywang.wxpay.bean.invoice.GeneralInvoiceRequest; +import com.github.binarywang.wxpay.bean.invoice.PassengerTransportInvoiceRequest; import com.github.binarywang.wxpay.bean.invoice.InvoiceResult; import com.github.binarywang.wxpay.bean.invoice.ReverseInvoiceRequest; import com.github.binarywang.wxpay.bean.invoice.InvoiceFileResult; @@ -67,6 +68,44 @@ public void shouldPostGeneralInvoiceToV3Endpoint() throws Exception { Assert.assertTrue(requestedBody.get().contains("\"fapiao_apply_id\":\"invoice-001\"")); } + @Test + public void shouldPostPassengerTransportInvoiceToV3Endpoint() throws Exception { + AtomicReference