财务系统发票验证:发票号、税号、金额校验
📋 目录
- 引言
- 一、发票验证的业务全景
- [1.1 一张发票上的三类标识](#1.1 一张发票上的三类标识 "#11-%E4%B8%80%E5%BC%A0%E5%8F%91%E7%A5%A8%E4%B8%8A%E7%9A%84%E4%B8%89%E7%B1%BB%E6%A0%87%E8%AF%86")
- [1.2 发票验证的 4 个层次](#1.2 发票验证的 4 个层次 "#12-%E5%8F%91%E7%A5%A8%E9%AA%8C%E8%AF%81%E7%9A%84-4-%E4%B8%AA%E5%B1%82%E6%AC%A1")
- [1.3 为什么"简单字段"最容易出事](#1.3 为什么"简单字段"最容易出事 "#13-%E4%B8%BA%E4%BB%80%E4%B9%88%E7%AE%80%E5%8D%95%E5%AD%97%E6%AE%B5%E6%9C%80%E5%AE%B9%E6%98%93%E5%87%BA%E4%BA%8B")
- 二、发票代码与发票号码
- [2.1 两段式结构:代码 + 号码](#2.1 两段式结构:代码 + 号码 "#21-%E4%B8%A4%E6%AE%B5%E5%BC%8F%E7%BB%93%E6%9E%84%E4%BB%A3%E7%A0%81--%E5%8F%B7%E7%A0%81")
- [2.2 发票代码的结构拆解](#2.2 发票代码的结构拆解 "#22-%E5%8F%91%E7%A5%A8%E4%BB%A3%E7%A0%81%E7%9A%84%E7%BB%93%E6%9E%84%E6%8B%86%E8%A7%A3")
- [2.3 发票号码的规则](#2.3 发票号码的规则 "#23-%E5%8F%91%E7%A5%A8%E5%8F%B7%E7%A0%81%E7%9A%84%E8%A7%84%E5%88%99")
- [2.4 全电发票:代码消失,号码变 20 位](#2.4 全电发票:代码消失,号码变 20 位 "#24-%E5%85%A8%E7%94%B5%E5%8F%91%E7%A5%A8%E4%BB%A3%E7%A0%81%E6%B6%88%E5%A4%B1%E5%8F%B7%E7%A0%81%E5%8F%98-20-%E4%BD%8D")
- [2.5 关键认知:发票代码没有校验位](#2.5 关键认知:发票代码没有校验位 "#25-%E5%85%B3%E9%94%AE%E8%AE%A4%E7%9F%A5%E5%8F%91%E7%A5%A8%E4%BB%A3%E7%A0%81%E6%B2%A1%E6%9C%89%E6%A0%A1%E9%AA%8C%E4%BD%8D")
- [2.6 ValidX 实现](#2.6 ValidX 实现 "#26-validx-%E5%AE%9E%E7%8E%B0")
- 三、纳税人识别号(税号)
- [3.1 三证合一:税号 = 统一社会信用代码](#3.1 三证合一:税号 = 统一社会信用代码 "#31-%E4%B8%89%E8%AF%81%E5%90%88%E4%B8%80%E7%A8%8E%E5%8F%B7--%E7%BB%9F%E4%B8%80%E7%A4%BE%E4%BC%9A%E4%BF%A1%E7%94%A8%E4%BB%A3%E7%A0%81")
- [3.2 各类主体的税号归属](#3.2 各类主体的税号归属 "#32-%E5%90%84%E7%B1%BB%E4%B8%BB%E4%BD%93%E7%9A%84%E7%A8%8E%E5%8F%B7%E5%BD%92%E5%B1%9E")
- [3.3 校验位算法:mod 31 + 17 个加权因子](#3.3 校验位算法:mod 31 + 17 个加权因子 "#33-%E6%A0%A1%E9%AA%8C%E4%BD%8D%E7%AE%97%E6%B3%95mod-31--17-%E4%B8%AA%E5%8A%A0%E6%9D%83%E5%9B%A0%E5%AD%90")
- [3.4 ValidX 的 @UnifiedSocialCreditCode](#3.4 ValidX 的 @UnifiedSocialCreditCode "#34-validx-%E7%9A%84-unifiedsocialcreditcode")
- [3.5 特殊主体:个人、境外企业、政府机构](#3.5 特殊主体:个人、境外企业、政府机构 "#35-%E7%89%B9%E6%AE%8A%E4%B8%BB%E4%BD%93%E4%B8%AA%E4%BA%BA%E5%A2%83%E5%A4%96%E4%BC%81%E4%B8%9A%E6%94%BF%E5%BA%9C%E6%9C%BA%E6%9E%84")
- 四、金额校验:发票的数学核心
- [4.1 金额三元组](#4.1 金额三元组 "#41-%E9%87%91%E9%A2%9D%E4%B8%89%E5%85%83%E7%BB%84")
- [4.2 税率表与适用范围](#4.2 税率表与适用范围 "#42-%E7%A8%8E%E7%8E%87%E8%A1%A8%E4%B8%8E%E9%80%82%E7%94%A8%E8%8C%83%E5%9B%B4")
- [4.3 三元组的勾稽关系](#4.3 三元组的勾稽关系 "#43-%E4%B8%89%E5%85%83%E7%BB%84%E7%9A%84%E5%8B%BE%E7%A8%BD%E5%85%B3%E7%B3%BB")
- [4.4 精度与尾差:BigDecimal 的正确用法](#4.4 精度与尾差:BigDecimal 的正确用法 "#44-%E7%B2%BE%E5%BA%A6%E4%B8%8E%E5%B0%BE%E5%B7%AEbigdecimal-%E7%9A%84%E6%AD%A3%E7%A1%AE%E7%94%A8%E6%B3%95")
- [4.5 明细行与合计的勾稽](#4.5 明细行与合计的勾稽 "#45-%E6%98%8E%E7%BB%86%E8%A1%8C%E4%B8%8E%E5%90%88%E8%AE%A1%E7%9A%84%E5%8B%BE%E7%A8%BD")
- [4.6 金额校验规则清单](#4.6 金额校验规则清单 "#46-%E9%87%91%E9%A2%9D%E6%A0%A1%E9%AA%8C%E8%A7%84%E5%88%99%E6%B8%85%E5%8D%95")
- [五、完整实现:发票 DTO + Service](#五、完整实现:发票 DTO + Service "#%E4%BA%94%E5%AE%8C%E6%95%B4%E5%AE%9E%E7%8E%B0%E5%8F%91%E7%A5%A8-dto--service")
- 六、常见坑与规避
- 七、总结
- 项目地址
引言
在财务 / 费控 / 报销 / 进销存系统里,发票几乎是必然出现的数据对象。它看起来很"标准"------一张发票上有发票代码、发票号码、开票方税号、受票方税号、金额、税额、价税合计......字段就那么几个,校验应该很简单?
恰恰相反。发票是中国税务体系里规则最复杂、版本迭代最频繁、又最不容出错的一类数据:
- 规则版本多:从 10 位代码到 12 位代码,再到全电发票(数电票)取消代码、号码变 20 位,十年间改了三次;
- 主体类型多:企业、个体工商户、自然人、境外企业、政府机构、事业单位,税号规则各不相同;
- 金额关系强 :不含税金额、税额、价税合计三者之间存在数学勾稽关系,错一个数字就会导致整张发票作废;
- 监管要求高:发票数据一旦入库,涉及税务合规、财务审计、发票查验,错误的容错空间几乎为零。
本文把发票验证拆成三块讲透------发票号(代码 + 号码)、税号(纳税人识别号)、金额(三元组勾稽) ,并给出可以直接落地的 ValidX 实现。文章最后会重点澄清一个常见误解:发票代码是没有校验位的------这意味着"格式校验"和"真伪校验"是两件完全不同的事,不能混为一谈。
一、发票验证的业务全景
1.1 一张发票上的三类标识
先建立全局观。一张增值税发票上,与"校验"相关的字段可以分成三类:
| 类别 | 字段 | 校验本质 |
|---|---|---|
| 标识类 | 发票代码、发票号码 | 长度 + 字符集 + 结构段合法性 |
| 主体类 | 销方税号、购方税号、销方名称、购方名称 | 校验位算法(税号)+ 名称与税号一致性 |
| 金额类 | 不含税金额、税率、税额、价税合计 | 数学勾稽关系 |
三类字段的校验难度递进:
标识类 ──► 主体类 ──► 金额类
(纯格式) (带校验位) (带勾稽关系)
容易 中等 最易出错
1.2 发票验证的 4 个层次
| 层次 | 做什么 | 能挡住什么 | 需要什么 |
|---|---|---|---|
| L1 格式校验 | 长度、字符集 | 手输错位、粘贴乱码 | 正则 / ValidX |
| L2 结构校验 | 分段合法性、种类码白名单 | 日期段非法、种类码不存在的代码 | 分段规则 |
| L3 算法校验 | 税号校验位 | 伪造税号、输错一位的税号 | mod 31 算法 |
| L4 勾稽校验 | 金额三元组关系 | 金额篡改、明细与合计不等 | BigDecimal 精算 |
| L5 真伪查验 | 调用税务总局查验平台 | 假发票、已作废发票、已红冲发票 | 税务接口 |
关键认知 :前 4 层是本地可做的 (本文的重点),第 5 层必须联网调用税务平台------本文不覆盖,但会在最后说明何时需要它。
1.3 为什么"简单字段"最容易出事
发票号码 = 8 位数字------听起来像一个 ^\d{8}$ 就能搞定。但真实项目中,这个字段出问题的概率极高:
java
// 反例:只校验长度
if (invoiceNo.length() != 8) {
throw new BizException("发票号码错误");
}
| 输入 | 上面的代码能挡住吗 | 应该挡住吗 |
|---|---|---|
"12345678" |
✅ 通过 | ✅ 应该通过 |
"1234 678" |
✅ 通过(长度 8,含空格) | ❌ 应该拦住 |
"1234567a" |
✅ 通过(长度 8,含字母) | ❌ 应该拦住 |
"12345678" |
✅ 通过(长度 8,全角数字) | ❌ 应该拦住 |
三个隐藏漏洞:空格、字母、全角字符。这正是 ValidX 存在的意义------把"长度"和"字符集"一次性表达清楚。
二、发票代码与发票号码
2.1 两段式结构:代码 + 号码
传统增值税发票使用两段式标识:
┌─────────────────────────┬──────────────┐
│ 发票代码 │ 发票号码 │
│ (发行批次标识) │ (顺序号) │
│ 10 / 12 位 │ 8 位 │
└─────────────────────────┴──────────────┘
- 发票代码:标识"这一批发票"------由税务机关印制并发放,同一批次的发票代码相同;
- 发票号码:标识"这一张发票"------同一批次内的顺序号。
业务含义 :代码 + 号码 才能唯一确定一张发票。这解释了为什么数据库里必须两个字段都存------只存号码会导致跨批次冲突。
2.2 发票代码的结构拆解
发票代码是分段编码------每一位(或每几位)有特定含义:
| 段 | 长度 | 含义 |
|---|---|---|
| 行政区划 | 4-5 位 | 与 GB/T 2260 行政区划代码同源(与身份证前 4 位一致) |
| 年份 | 2 位 | 印制年份的后两位 |
| 印制批次 | 1 位 | 同一年度内的批次号 |
| 发票种类 | 1 位 | 标识发票类型(专票 / 普票 / 电子票等) |
| 联次 | 1 位 | 单联 / 两联 / 三联...... |
| 金额版本号 | 1-2 位 | 金额栏版本标识 |
⚠️ 重要提示 :发票代码的具体位数分配随版本演进调整过 (10 位版 → 12 位版),不同省份、不同票种的实施细则也可能有差异。生产环境应以税务总局公布的最新编码规则为准 。本文的重点是讲清"如何做结构校验"的方法,而不是给出一个永久有效的位序表。
由此得出可落地的结构校验规则:
java
public final class InvoiceCodeRules {
/** 发票代码可能的历史长度 */
private static final Set<Integer> VALID_LENGTHS = Set.of(10, 12);
/** 发票种类码白名单(常见值,可按票种扩展) */
private static final Set<Character> TYPE_CODES = Set.of(
'1', // 增值税专用发票
'2', // 货物运输业增值税专用发票
'3', // 机动车销售统一发票
'4', // 增值税普通发票
'5', // 二手车销售统一发票
'6', // 增值税普通发票(卷票)
'7', // 增值税电子普通发票
'8' // 增值税电子专用发票
);
/**
* 结构校验:长度合法 + 纯数字
*/
public static boolean isStructurallyValid(String code) {
if (code == null || !VALID_LENGTHS.contains(code.length())) {
return false;
}
for (int i = 0; i < code.length(); i++) {
if (!Character.isDigit(code.charAt(i))) {
return false;
}
}
return true;
}
/**
* 行政区划段是否合法(复用身份证的省 / 市校验逻辑)
*/
public static boolean hasValidRegionPrefix(String code) {
if (!isStructurallyValid(code)) {
return false;
}
String region = code.substring(0, 4); // 前 4 位 = 省级 + 市级
return RegionCodeRegistry.contains(region);
}
}
这里有一个值得反复强调的洞察 :发票代码的前 4 位(行政区划)和身份证前 4 位、统一社会信用代码的行政区划段是同源的------都来自 GB/T 2260。这意味着:
如果你已经有一套"行政区划码注册表"(做身份证校验时建的),可以直接复用到发票代码校验上,不需要再维护一份。这也是把校验逻辑做成"公共库"而不是散落各处的价值所在。
2.3 发票号码的规则
| 票种 | 号码长度 | 字符集 |
|---|---|---|
| 传统增值税专用发票 | 8 位 | 纯数字 |
| 传统增值税普通发票 | 8 位 | 纯数字 |
| 卷式发票 | 8 位 | 纯数字 |
| 机动车销售统一发票 | 8 位 | 纯数字 |
| 全电发票(数电票) | 20 位 | 纯数字 |
发票号码的校验要点:长度 + 字符集 + 前导零有意义。
java
// 发票号码前导零必须保留!
String no = "00012345"; // 合法
String no2 = "12345"; // 非法------可能被 Excel / 数据库丢了前导零
血泪教训 :发票号码存进 Excel 再导入,前导零会被自动吃掉。这是财务系统对接中最常见的数据事故之一。对策 :接口一律用
String接收、数据库用VARCHAR存储、导入模板把该列设为"文本格式"。这正是 ValidX 的@FixedLength默认不做 trim、不改输入 的设计价值------校验器不能悄悄把00012345变成你认为的样子。
2.4 全电发票:代码消失,号码变 20 位
2021 年起,税务总局推行全面数字化的电子发票(简称"数电票"、"全电发票"),它带来三个根本变化:
| 变化 | 传统发票 | 全电发票 |
|---|---|---|
| 发票代码 | 有(10 / 12 位) | 无 |
| 发票号码 | 8 位 | 20 位 |
| 载体 | 纸质 or PDF/OFD | 纯数字化(XML/JSON) |
| 开具方式 | 税控设备 | 电子发票服务平台 |
对校验代码的影响 :不能写死"代码 12 位 + 号码 8 位",必须按票种分支:
java
public boolean validateInvoiceIdentity(String invoiceCode, String invoiceNo,
InvoiceType type) {
ValidX v = ValidX.init().withLocale(Locale.SIMPLIFIED_CHINESE);
if (type == InvoiceType.FULLY_DIGITAL) {
// 全电发票:只有 20 位号码,没有代码
if (invoiceCode != null && !invoiceCode.isEmpty()) {
v.field("发票代码").getErrors().add("全电发票不应填写发票代码");
}
v.field("发票号码").isFixedLength(invoiceNo, 20);
} else {
// 传统发票:代码 + 8 位号码
v.field("发票代码").isFixedLength(invoiceCode, 12)
.isFixedLength(invoiceNo, 8);
}
return v.passed();
}
2.5 关键认知:发票代码没有校验位
这是本文最想澄清的一点。
发票代码和发票号码都没有校验位算法 ------不像身份证(mod 11)、银行卡(Luhn)、统一社会信用代码(mod 31),发票代码无法通过任何算法验证"这个号码是否真实存在"。
这意味着:
| 你只能做到 | 你做不到 |
|---|---|
| 长度对不对 | 这张发票是否真实开具 |
| 是不是纯数字 | 是否已被红冲 / 作废 |
| 行政区划段是否合法 | 金额有没有被篡改 |
| 种类码是否在白名单 | 开票方是不是真的开票方 |
必须联网查验 :税务总局"全国增值税发票查验平台"是唯一的权威校验入口。任何声称"用算法就能验证发票真伪"的方案都是误导。
java
/**
* L5:真伪查验(必须联网,本文只给接口形态)
*/
public interface InvoiceVerificationClient {
/**
* @param invoiceCode 发票代码(全电发票传空)
* @param invoiceNo 发票号码
* @param invoiceDate 开票日期(yyyy-MM-dd)
* @param amount 价税合计(不含税金额 + 税额)
* @param checkCode 校验码后 6 位(专票为开票日期,普票为校验码)
*/
VerificationResult verify(String invoiceCode, String invoiceNo,
String invoiceDate, BigDecimal amount,
String checkCode);
}
2.6 ValidX 实现
java
@Data
public class InvoiceIdentityRequest {
/** 发票代码(全电发票可为空) */
@FixedLength(length = 12, message = "发票代码必须为 12 位")
private String invoiceCode;
/** 发票号码(传统 8 位 / 全电 20 位) */
@NotBlank(message = "发票号码不能为空")
@Pattern(regexp = "^\\d{8}$|^\\d{20}$", message = "发票号码必须为 8 位或 20 位数字")
private String invoiceNo;
}
关于
@FixedLength:这是 ValidX v1.2.1 引入的新注解,专门解决"定长校验"这一高频需求。它比@Pattern(regexp = "^\\d{8}$")更具可读性,且内置了 CHAR / CODE_POINT / GRAPHEME 三种长度计数口径。若你使用的是更早版本,用@Pattern等价替换即可。
链式 API 版本(适合动态表单 / 程序化场景):
java
@Service
public class InvoiceIdentityValidator {
public void validate(String invoiceCode, String invoiceNo, boolean fullyDigital) {
ValidX v = ValidX.init().withLocale(Locale.SIMPLIFIED_CHINESE);
if (fullyDigital) {
v.field("发票号码").isFixedLength(invoiceNo, 20);
} else {
// 注意:默认不 trim,前导零不会被吃掉
v.field("发票代码").isFixedLength(invoiceCode, 12)
.field("发票号码").isFixedLength(invoiceNo, 8);
}
if (!v.passed()) {
throw new InvoiceException(v.getErrors());
}
}
}
三、纳税人识别号(税号)
3.1 三证合一:税号 = 统一社会信用代码
2015 年"三证合一"改革之后(营业执照、组织机构代码证、税务登记证合一),企业纳税人的识别号规则彻底简化了:
企业纳税人识别号 = 统一社会信用代码(18 位)
这带来了一个巨大的工程红利:
| 时间 | 企业税号规则 | 校验方式 |
|---|---|---|
| 2015 年前 | 15 位(区域码 6 + 组织机构代码 9) | 需要单独算法 |
| 2015 年后 | 18 位统一社会信用代码 | @UnifiedSocialCreditCode 直接覆盖 |
结论 :今天的财务系统,企业税号校验可以直接复用 ValidX 的 @UnifiedSocialCreditCode------不需要再写一套独立的税号校验逻辑。这是"三证合一"送给工程师的一份礼物。
3.2 各类主体的税号归属
| 主体类型 | 税号形式 | ValidX 注解 |
|---|---|---|
| 企业(含公司、分公司) | 18 位统一社会信用代码 | @UnifiedSocialCreditCode |
| 个体工商户 | 18 位统一社会信用代码 | @UnifiedSocialCreditCode |
| 农民专业合作社 | 18 位统一社会信用代码 | @UnifiedSocialCreditCode |
| 事业单位 / 社会团体 | 18 位统一社会信用代码 | @UnifiedSocialCreditCode |
| 中国籍自然人 | 18 位居民身份证号 | @ChineseIdCard |
| 外籍自然人 | 护照号 / 外国人永久居留身份证 | @ChinesePassport / @ForeignerPermanentResidenceIdentity |
| 境外企业 | 无统一代码,由税务另行赋码 | 无固定格式,业务层放行 |
| 政府机构 | 18 位统一社会信用代码 | @UnifiedSocialCreditCode |
实战建议 :把"是自然人还是企业"作为显式字段 (buyerType),而不是让校验器去猜------猜错会导致合法发票被拒。
3.3 校验位算法:mod 31 + 17 个加权因子
统一社会信用代码(GB 32100-2015)的校验算法如下:
字符集 (31 个字符,去掉了易混淆的 I、O、S、V、Z):
0123456789ABCDEFGHJKLMNPQRTUWXY
加权因子(前 17 位,第 18 位是校验位):
java
private static final int[] WEIGHT = {
1, 3, 9, 27, 19, 26, 16, 17, 20, 29, 25, 13, 8, 24, 10, 30, 28
};
计算步骤:
markdown
1. 前 17 位字符 → 按字符集索引映射为 0-30 的数值
2. 逐位乘以对应加权因子,求和
3. 校验码索引 = 31 - (sum mod 31),若结果 = 31 则取 0
4. 第 18 位必须等于字符集[校验码索引]
完整 Java 实现 (摘自 ValidX 的 UnifiedSocialCreditCodeValidator):
java
public class UnifiedSocialCreditCodeValidator
implements ConstraintValidator<UnifiedSocialCreditCode, String> {
private static final Map<Character, Integer> CODE_MAP = new HashMap<>();
private static final int[] WEIGHT =
{1, 3, 9, 27, 19, 26, 16, 17, 20, 29, 25, 13, 8, 24, 10, 30, 28};
private static final char[] CHECK_CODES =
"0123456789ABCDEFGHJKLMNPQRTUWXY".toCharArray();
static {
for (int i = 0; i < CHECK_CODES.length; i++) {
CODE_MAP.put(CHECK_CODES[i], i);
}
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isEmpty()) {
return true; // 空值交给 @NotNull 处理
}
if (value.length() != 18) {
return false;
}
// 1. 字符集校验
for (int i = 0; i < 18; i++) {
if (!CODE_MAP.containsKey(value.charAt(i))) {
return false;
}
}
// 2. 校验位算法
int sum = 0;
for (int i = 0; i < 17; i++) {
sum += CODE_MAP.get(value.charAt(i)) * WEIGHT[i];
}
int idx = 31 - (sum % 31);
if (idx == 31) {
idx = 0;
}
return CHECK_CODES[idx] == value.charAt(17);
}
}
算法的检错能力:
| 错误类型 | 能否检出 |
|---|---|
| 单字符替换 | ✅ 100% |
| 相邻两字符互换 | ✅ 绝大多数(权重不对称) |
| 长度错(17 / 19 位) | ✅ 100% |
| 多字符同时错 | ⚠️ 部分 |
为什么是 mod 31? 因为字符集恰好是 31 个字符------余数空间和字符空间一一对应,不需要
X这样的特殊字符 。这是相比身份证 mod 11(需要X)的一个工程优势。
3.4 ValidX 的 @UnifiedSocialCreditCode
注解方式:
java
@Data
public class InvoiceDTO {
/** 销方税号(企业 → 18 位统一社会信用代码)*/
@NotBlank(message = "销方税号不能为空")
@UnifiedSocialCreditCode(message = "销方税号格式不正确")
private String sellerTaxNo;
/** 购方税号(可能是企业,也可能是自然人身份证)*/
@NotBlank(message = "购方税号不能为空")
private String buyerTaxNo;
}
链式 API 方式(适合购方可能是企业或自然人的分支场景):
java
@Service
public class TaxNoValidator {
public void validate(String taxNo, BuyerType buyerType) {
ValidX v = ValidX.init().withLocale(Locale.SIMPLIFIED_CHINESE)
.field("纳税人识别号");
switch (buyerType) {
case ENTERPRISE:
v.isUnifiedSocialCreditCode(taxNo);
break;
case INDIVIDUAL:
v.isChineseIdCard(taxNo);
break;
case OVERSEAS:
// 境外企业无统一格式,只做长度与字符集兜底
if (taxNo != null && (taxNo.length() < 8 || taxNo.length() > 20)) {
v.getErrors().add("境外企业税号长度应在 8-20 位之间");
}
break;
}
if (!v.passed()) {
throw new InvoiceException(v.getErrors());
}
}
}
3.5 特殊主体:个人、境外企业、政府机构
| 场景 | 坑 | 对策 |
|---|---|---|
| 购方是自然人 | 直接拿企业规则校验会失败 | 用 buyerType 分支,自然人走 @ChineseIdCard |
| 外籍自然人 | 没有身份证号 | 走护照 / 永久居留证规则 |
| 境外企业 | 无统一社会信用代码 | 业务层放行,只做长度兜底 |
| 政府机构 | 有统一社会信用代码,但常以"机关"结尾 | 名称与税号要分别校验,不能互相推断 |
| 税号与前缀不符 | 如 91 开头却是"XX 酒店" |
需要业态码 + 名称关键词的交叉校验(业务规则) |
一个常见的业务规则 :统一社会信用代码的第 1 位是登记管理部门代码 ,第 2 位是机构类别代码:
java
/**
* 登记管理部门代码(第 1 位)
* 1 = 机构编制
* 5 = 民政
* 9 = 工商(市场主体,最常见)
* Y = 其他
*/
private static boolean isBusinessEntity(String creditCode) {
return creditCode.charAt(0) == '9'; // 工商登记 = 市场主体
}
注意 :这是业务规则 ,不是格式规则。做"是否为企业"的判断时可以用,但不要写进格式校验器------格式校验器应该保持纯粹。
四、金额校验:发票的数学核心
4.1 金额三元组
发票金额从来不是单个数字,而是三元组:
scss
┌──────────────────┐ + ┌────────────┐ = ┌──────────────┐
│ 不含税金额 │ │ 税额 │ │ 价税合计 │
│ (金额/Amount) │ │ (Tax) │ │ (Total) │
└──────────────────┘ └────────────┘ └──────────────┘
│ │ │
└────────────────────────┴─────────────────────┘
三者必须满足钩稽关系
对应的 DTO 字段:
java
/** 不含税金额 */
private BigDecimal amount;
/** 税额 */
private BigDecimal taxAmount;
/** 价税合计 */
private BigDecimal totalAmount;
/** 税率(如 0.13 表示 13%)*/
private BigDecimal taxRate;
4.2 税率表与适用范围
现行增值税税率(一般纳税人):
| 税率 | 适用范围 | 典型行业 |
|---|---|---|
| 13% | 销售货物、加工修理修配、有形动产租赁 | 制造业、批发零售 |
| 9% | 交通运输、邮政、基础电信、建筑、不动产租赁、农产品 | 物流、建筑、房地产 |
| 6% | 现代服务、生活服务、金融服务、增值电信 | 咨询、餐饮、酒店、IT 服务 |
| 3% | 小规模纳税人(征收率) | 小微企业 |
| 1% | 小规模纳税人阶段性优惠 | 2020 年起阶段性政策 |
| 0% | 出口货物、跨境服务 | 外贸 |
java
/**
* 合法税率白名单(以 0.01 为最小单位)
*/
private static final Set<BigDecimal> VALID_RATES = Set.of(
new BigDecimal("0.00"),
new BigDecimal("0.01"),
new BigDecimal("0.03"),
new BigDecimal("0.05"),
new BigDecimal("0.06"),
new BigDecimal("0.09"),
new BigDecimal("0.10"), // 部分特定业务
new BigDecimal("0.13"),
new BigDecimal("0.16") // 历史税率,存量发票兼容
);
实战提醒 :税率白名单必须可配置 。税率调整(如 2018 年 17%→16%、2019 年 16%→13%)会导致历史发票的税率"不在白名单里"。建议按开票日期选择对应的税率白名单,而不是用一套固定的。
4.3 三元组的勾稽关系
这是发票校验中最容易出错、也最有价值的部分。
基本勾稽关系:
scss
① 税额 = 不含税金额 × 税率
② 价税合计 = 不含税金额 + 税额
③ 不含税金额 = 价税合计 ÷ (1 + 税率)
三个关系式里,任意两个成立即可推导出第三个。所以校验时不需要三个都验------验两个最可靠的即可。
示例验算:
| 不含税金额 | 税率 | 税额(理论值) | 税额(发票值) | 价税合计 |
|---|---|---|---|---|
| 1000.00 | 13% | 130.000 | 130.00 | 1130.00 |
| 1000.00 | 6% | 60.000 | 60.00 | 1060.00 |
| 1234.56 | 13% | 160.4928 | 160.49 | 1395.05 |
注意第三行------这就是"尾差"的来源。
4.4 精度与尾差:BigDecimal 的正确用法
4.4.1 为什么必须用 BigDecimal
java
// 反例:double 精度丢失
double amount = 1234.56;
double rate = 0.13;
double tax = amount * rate;
System.out.println(tax); // 160.4928 00000000002 ------ 浮点误差!
System.out.println(0.1 + 0.2); // 0.30000000000000004
金额相关的所有计算必须使用 BigDecimal,这是铁律。
4.4.2 尾差的成因
税务机关的规则是税额四舍五入到分 ,而 不含税金额 × 税率 通常有更多小数位:
ini
1234.56 × 0.13 = 160.4928
↑ 四舍五入到分 → 160.49
发票上:金额 1234.56 + 税额 160.49 = 价税合计 1395.05
反向验算:1395.05 ÷ 1.13 = 1234.5575... ≠ 1234.56
所以"三元组精确相等"是做不到的------必须容差。
4.4.3 容差的合理取值
| 容差策略 | 值 | 适用 |
|---|---|---|
| 严格 | 0 | ❌ 不可用,尾差必然存在 |
| 分位容差 | 0.01 |
✅ 单行发票的常见选择 |
| 分行容差 | 0.01 × 行数 |
✅ 多明细行的发票 |
| 宽松容差 | 0.06 |
⚠️ 过度宽松,会放过真实错误 |
推荐实现:
java
public final class AmountReconciliation {
/** 单行允许的分位尾差 */
private static final BigDecimal LINE_TOLERANCE = new BigDecimal("0.01");
/**
* 校验 税额 = 金额 × 税率(容差 ±0.01)
*/
public static boolean checkTax(BigDecimal amount, BigDecimal rate,
BigDecimal tax) {
if (amount == null || rate == null || tax == null) {
return false;
}
BigDecimal expected = amount.multiply(rate)
.setScale(2, RoundingMode.HALF_UP);
return tax.subtract(expected).abs()
.compareTo(LINE_TOLERANCE) <= 0;
}
/**
* 校验 价税合计 = 金额 + 税额(容差 ±0.01)
*/
public static boolean checkTotal(BigDecimal amount, BigDecimal tax,
BigDecimal total) {
if (amount == null || tax == null || total == null) {
return false;
}
BigDecimal expected = amount.add(tax);
return total.subtract(expected).abs()
.compareTo(LINE_TOLERANCE) <= 0;
}
}
compareTo而不是equals:BigDecimal的equals会比较精度 ------new BigDecimal("100.0").equals(new BigDecimal("100.00"))返回false!金额比较必须用compareTo。这是 Java 财务代码的第一大坑。
4.5 明细行与合计的勾稽
真实发票可能有多个明细行,需要两层勾稽:
yaml
┌───────────────────────────────────────────┐
│ 明细行 1:金额 100.00 税额 13.00 │
│ 明细行 2:金额 200.00 税额 26.00 │
│ 明细行 3:金额 300.00 税额 39.00 │
├───────────────────────────────────────────┤
│ 合计: 金额 600.00 税额 78.00 1130.00│ ← 必须等于各行之和
└───────────────────────────────────────────┘
校验规则:
java
/**
* 明细行合计与发票合计的勾稽(容差 = 0.01 × 行数)
*/
public static boolean checkLineSum(List<InvoiceLine> lines,
BigDecimal headerAmount,
BigDecimal headerTax) {
if (lines == null || lines.isEmpty()) {
return true;
}
BigDecimal sumAmount = lines.stream()
.map(InvoiceLine::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal sumTax = lines.stream()
.map(InvoiceLine::getTaxAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 容差随行数放大(每行最多 0.01 的分位误差)
BigDecimal tolerance = new BigDecimal("0.01")
.multiply(BigDecimal.valueOf(lines.size()));
return headerAmount.subtract(sumAmount).abs().compareTo(tolerance) <= 0
&& headerTax.subtract(sumTax).abs().compareTo(tolerance) <= 0;
}
为什么容差要乘以行数? 因为每一行都可能有 ±0.01 的四舍五入误差,10 行发票的理论最大误差就是 0.10。用固定 0.01 会导致多行发票频繁误判。
4.6 金额校验规则清单
把上面的内容整理成一张可勾选的清单:
| # | 规则 | 类型 | 实现位置 |
|---|---|---|---|
| 1 | 金额、税额、价税合计均 ≥ 0 | 字段级 | @DecimalMin("0.00") |
| 2 | 金额、税额、价税合计均 ≤ 上限 | 字段级 | @DecimalMax("999999999.99") |
| 3 | 小数位不超过 2 位 | 字段级 | @Digits(integer = 12, fraction = 2) |
| 4 | 税率在白名单内 | 字段级 | @In / 自定义 |
| 5 | 税额 ≈ 金额 × 税率 | 跨字段 | AmountReconciliation.checkTax |
| 6 | 价税合计 ≈ 金额 + 税额 | 跨字段 | AmountReconciliation.checkTotal |
| 7 | 明细行合计 ≈ 发票合计 | 跨字段 | checkLineSum |
| 8 | 折扣行金额 ≤ 原行金额 | 跨字段 | 业务规则 |
| 9 | 价税合计与含税单价 × 数量一致 | 跨字段 | 业务规则 |
注意第 1-4 条是"字段级",可以用注解;第 5-9 条是"跨字段",必须用 Service 层逻辑------这是 ValidX 用注解 + 链式 API 双轨的典型分工。
五、完整实现:发票 DTO + Service
5.1 请求 DTO
java
@Data
public class InvoiceSaveRequest {
// ==================== 标识类 ====================
/** 发票代码(全电发票为空) */
@FixedLength(length = 12, message = "发票代码必须为 12 位")
private String invoiceCode;
/** 发票号码(传统 8 位 / 全电 20 位) */
@NotBlank(message = "发票号码不能为空")
@Pattern(regexp = "^\\d{8}$|^\\d{20}$", message = "发票号码必须为 8 位或 20 位数字")
private String invoiceNo;
/** 开票日期 */
@NotBlank(message = "开票日期不能为空")
@Date(pattern = "yyyy-MM-dd", message = "开票日期格式不正确")
private String invoiceDate;
// ==================== 主体类 ====================
/** 销方名称 */
@NotBlank(message = "销方名称不能为空")
@Size(min = 2, max = 100, message = "销方名称长度需在 2-100 字之间")
private String sellerName;
/** 销方税号(企业 → 统一社会信用代码) */
@NotBlank(message = "销方税号不能为空")
@UnifiedSocialCreditCode(message = "销方税号格式不正确")
private String sellerTaxNo;
/** 购方名称 */
@NotBlank(message = "购方名称不能为空")
@Size(min = 2, max = 100, message = "购方名称长度需在 2-100 字之间")
private String buyerName;
/** 购方税号(企业 or 自然人) */
@NotBlank(message = "购方税号不能为空")
private String buyerTaxNo;
/** 购方主体类型(决定税号校验规则) */
@NotBlank(message = "购方主体类型不能为空")
@In({"ENTERPRISE", "INDIVIDUAL", "OVERSEAS"})
private String buyerType;
// ==================== 金额类 ====================
/** 不含税金额 */
@NotNull(message = "不含税金额不能为空")
@DecimalMin(value = "0.00", message = "金额不能为负数")
@Digits(integer = 12, fraction = 2, message = "金额最多 2 位小数")
private BigDecimal amount;
/** 税率(0.13 = 13%) */
@NotNull(message = "税率不能为空")
@DecimalMin(value = "0.00", message = "税率不能为负数")
@DecimalMax(value = "1.00", message = "税率不能超过 100%")
private BigDecimal taxRate;
/** 税额 */
@NotNull(message = "税额不能为空")
@DecimalMin(value = "0.00", message = "税额不能为负数")
@Digits(integer = 12, fraction = 2, message = "税额最多 2 位小数")
private BigDecimal taxAmount;
/** 价税合计 */
@NotNull(message = "价税合计不能为空")
@DecimalMin(value = "0.00", message = "价税合计不能为负数")
@Digits(integer = 12, fraction = 2, message = "价税合计最多 2 位小数")
private BigDecimal totalAmount;
// ==================== 明细 ====================
@Valid
@Size(max = 100, message = "明细行不能超过 100 行")
private List<InvoiceLine> lines;
}
注意
@In的写法 :ValidX 的@In属性名是单数value(String[] value()),不是values。写@In(values = {...})会编译失败。可以简写为@In({"A", "B"})。
5.2 跨字段校验 Service
java
@Service
public class InvoiceValidationService {
private static final Set<BigDecimal> VALID_RATES = Set.of(
new BigDecimal("0.00"), new BigDecimal("0.01"),
new BigDecimal("0.03"), new BigDecimal("0.05"),
new BigDecimal("0.06"), new BigDecimal("0.09"),
new BigDecimal("0.10"), new BigDecimal("0.13"),
new BigDecimal("0.16")
);
/**
* 发票完整校验:字段级(注解已完成)+ 跨字段(本方法)
*/
public void validate(InvoiceSaveRequest req) {
ValidX v = ValidX.init().withLocale(Locale.SIMPLIFIED_CHINESE);
// ============ 1. 税号按主体类型分支校验 ============
validateTaxNo(v, req.getSellerTaxNo(), "ENTERPRISE", "销方税号");
validateTaxNo(v, req.getBuyerTaxNo(), req.getBuyerType(), "购方税号");
// ============ 2. 税率白名单 ============
if (req.getTaxRate() != null
&& !VALID_RATES.contains(req.getTaxRate())) {
v.field("税率").getErrors()
.add("税率 " + req.getTaxRate() + " 不在合法范围内");
}
// ============ 3. 金额勾稽 ============
if (!AmountReconciliation.checkTax(
req.getAmount(), req.getTaxRate(), req.getTaxAmount())) {
v.field("税额").getErrors()
.add("税额与「金额 × 税率」不匹配(允许 ±0.01 尾差)");
}
if (!AmountReconciliation.checkTotal(
req.getAmount(), req.getTaxAmount(), req.getTotalAmount())) {
v.field("价税合计").getErrors()
.add("价税合计应等于「金额 + 税额」(允许 ±0.01 尾差)");
}
// ============ 4. 明细勾稽 ============
if (req.getLines() != null && !req.getLines().isEmpty()) {
if (!AmountReconciliation.checkLineSum(
req.getLines(), req.getAmount(), req.getTaxAmount())) {
v.field("明细").getErrors()
.add("明细行合计与发票金额不一致");
}
}
if (!v.passed()) {
throw new InvoiceValidationException(v.getErrors());
}
}
private void validateTaxNo(ValidX v, String taxNo,
String buyerType, String fieldLabel) {
switch (buyerType) {
case "ENTERPRISE":
v.field(fieldLabel).isUnifiedSocialCreditCode(taxNo);
break;
case "INDIVIDUAL":
v.field(fieldLabel).isChineseIdCard(taxNo);
break;
case "OVERSEAS":
if (taxNo != null && (taxNo.length() < 8 || taxNo.length() > 20)) {
v.field(fieldLabel).getErrors()
.add(fieldLabel + "(境外)长度应在 8-20 位之间");
}
break;
default:
v.field(fieldLabel).getErrors().add("未知的购方主体类型:" + buyerType);
}
}
}
5.3 Controller
java
@RestController
@RequestMapping("/api/v1/invoices")
public class InvoiceController {
@Autowired
private InvoiceValidationService validationService;
@Autowired
private InvoiceService invoiceService;
@PostMapping
public Result<InvoiceVO> save(@Valid @RequestBody InvoiceSaveRequest req) {
// 第一层:注解校验(由 @Valid 自动触发)
// 第二层:跨字段校验
validationService.validate(req);
// 第三层:真伪查验(可选,异步)
return Result.success(invoiceService.save(req));
}
}
六、常见坑与规避
6.1 坑一:发票号码前导零被吃掉
现象 :00012345 入库后变成 12345。
根因 :Excel 导入时该列被识别为"数值";或数据库字段用了 INT/BIGINT。
规避:
- DTO 用
String接收(本文示例已这么做); - 数据库用
VARCHAR(20); - 导入模板把该列设为文本格式;
- 校验层不要 trim ------
@FixedLength默认不 trim,正好符合需求。
6.2 坑二:BigDecimal 用 equals 比较
java
new BigDecimal("100.0").equals(new BigDecimal("100.00")) // false !
new BigDecimal("100.0").compareTo(new BigDecimal("100.00")) // 0 → 相等
规避 :金额比较一律用 compareTo(...) == 0 ,或统一 setScale(2) 后再比。
6.3 坑三:税额用了"价税合计 × 税率"算
java
// 错误:用价税合计算税额
tax = totalAmount.multiply(rate);
// 正确:用不含税金额算税额
tax = amount.multiply(rate);
价税合计 × 税率 得到的是含税税额,不是发票上的税额。这个错误在小额发票上误差小,容易被忽略,但在大额发票上会直接导致校验失败。
6.4 坑四:把"格式校验"当成"真伪校验"
必须牢记 :发票代码没有校验位 。通过了长度、字符集、结构校验的发票号码,完全可能是伪造的。
| 校验层次 | 能挡住 | 需要 |
|---|---|---|
| 格式 + 结构 | 手输错、粘贴错 | ValidX |
| 税号算法 | 伪造税号 | ValidX |
| 金额勾稽 | 金额篡改 | BigDecimal |
| 真伪查验 | 假发票、已作废、已红冲 | 税务总局平台 |
规避 :在需求评审阶段就明确"是否需要真伪查验",并预留异步查验通道。不要承诺"校验通过就是真发票"。
6.5 坑五:全电发票与纸质发票混用一套规则
现象:全电发票没有发票代码,代码字段为空的请求被"发票代码必填"规则拒掉。
规避 :引入 invoiceType 字段,按票种分支校验:
java
if (req.getInvoiceType() == InvoiceType.FULLY_DIGITAL) {
Assert.isTrue(StringUtils.isBlank(req.getInvoiceCode()),
"全电发票不应填写发票代码");
} else {
Assert.isTrue(StringUtils.isNotBlank(req.getInvoiceCode()),
"传统发票必须填写发票代码");
}
6.6 坑六:税率白名单写死
现象:2019 年税率从 16% 调到 13% 后,存量发票校验大面积失败。
规避:
- 税率白名单按开票日期选择版本;
- 或把白名单做成可配置项(Nacos / Apollo / 数据库);
- 保留历史税率(
0.16、0.17)以兼容存量数据。
6.7 坑七:明细行容差用了固定 0.01
现象:10 行明细的发票频繁校验失败。
规避 :容差 = 0.01 × 行数(见 checkLineSum 实现)。
6.8 坑八:忽略"折扣行"
现象 :折扣金额是负数,被 @DecimalMin("0.00") 拒掉。
规避 :折扣行是独立概念(金额可为负),应单独建模,或对折扣行放宽 @DecimalMin 限制。
七、总结
发票校验是"看起来简单、做对很难"的典型。本文把三块核心内容梳理如下:
核心知识点回顾
| 模块 | 关键结论 |
|---|---|
| 发票号码 | 传统 8 位、全电 20 位;前导零有意义,绝不能丢 |
| 发票代码 | 10 / 12 位;没有校验位;行政区划段与身份证同源,可复用注册表 |
| 税号 | 三证合一后企业税号 = 统一社会信用代码 ,直接用 @UnifiedSocialCreditCode |
| 税号算法 | mod 31 + 17 个加权因子,字符集 31 个(去 I/O/S/V/Z) |
| 金额勾稽 | 税额 = 金额 × 税率、价税合计 = 金额 + 税额,必用 BigDecimal |
| 尾差 | 必须容差:单行 ±0.01,多行 ±0.01 × 行数 |
| 能力边界 | 本地校验只能验"格式 + 结构 + 算法 + 勾稽",真伪必须联网查验 |
ValidX 在本场景的使用矩阵
| 需求 | 推荐用法 |
|---|---|
| 发票号码长度 | @FixedLength(length = 8) 或 @Pattern |
| 企业税号 | @UnifiedSocialCreditCode |
| 个人税号 | @ChineseIdCard |
| 主体类型枚举 | @In({"ENTERPRISE", "INDIVIDUAL", "OVERSEAS"}) |
| 开票日期 | @Date(pattern = "yyyy-MM-dd") |
| 金额范围与精度 | @DecimalMin + @Digits |
| 税率白名单 | 链式 API + Set.contains |
| 金额勾稽 | 链式 API + BigDecimal(跨字段,注解做不了) |
最关键的一条设计原则:
字段级校验用注解,跨字段校验用链式 API。
发票的金额勾稽天生是"多字段联合"的规则------税额 单独看永远合法,只有和 金额、税率 放在一起才能判定对错。这正是 ValidX 同时提供注解 和链式 API 两套入口的原因:前者解决"单字段格式",后者解决"多字段关系"。
与本文相关的系列文章
- 《ValidX 组织机构代码验证:9 位代码校验位算法》:本篇的税号算法与统一社会信用代码同源,那篇文章讲清了 mod 11 / mod 31 的演进脉络;
- 《ValidX 股票代码验证:沪深港美股票代码格式大全》:金融场景的另一类标识校验;
- 《ValidX 银行号卡验证与 Luhn 算法》:mod 10 校验的经典案例,三种模数(10 / 11 / 31)对照阅读收获更大;
- 《退款申请验证:订单状态、退款金额、原因校验》:同属财务场景,与本篇的金额校验方法可直接复用。
项目地址
- GitHub: github.com/vipxieliang...
- Gitee: gitee.com/vipxieliang...