Go 实现连续劳务佣金实时计税:表设计、接口、月度汇总与年度汇算
佣金系统每产生一笔可确认的个人劳务收入,就需要回答:本笔预扣多少个税,个人实收多少,为什么是这个数,以及月底如何和申报数据核对。
本文从一笔佣金的实时处理出发,设计 Go 计税核心、MySQL 表结构、HTTP 接口、月度汇总和年度汇算估算。计算由收入事件触发;同一个月可以发生任意多笔,月份只参与累计费用计算。
政策核对日期:2026 年 11 月 3 日。依据是国家税务总局公告 2025 年第 16 号及其官方解读。后面的逐笔账本、事务和接口是工程设计;“实时”不表示税务机关要求逐笔实时提交申报。
一、先确认佣金属于哪一种所得
“佣金”是业务名称,不能单靠这个字段选税率。本文讨论的是:适用相关公告的互联网平台企业,为居民个人取得的平台劳务报酬办理累计预扣。
官方解读明确了适用企业范围,包括按规定报送平台企业基本信息的企业及负责结算的相关运营主体,并区分劳务报酬与经营所得。例如平台推广、设计、咨询等服务可能形成劳务报酬;不能把所有个人收款都归入本文模型。应结合主体、合同和实际业务确认所得分类。参见官方解读第二、四问。
建议在进入计税服务前完成业务分类:
| 输入事项 | 系统需要确认的内容 |
|---|---|
| 付款方 | 平台企业及实际扣缴主体,适用的政策版本 |
| 收款方 | 已核验的自然人身份、居民身份及稳定纳税人标识 |
| 所得类别 | 劳务报酬、工资薪金、经营所得等分别路由 |
| 收入金额 | 经过税务口径映射的本笔劳务收入,不直接拿订单 GMV 或提现额代替 |
| 所属期间 | 实际取得收入对应的时间及月度,统一按中国时区确定 |
本文示例的 gross_cents 表示已确认属于本模型的劳务收入金额,单位为分,且已明确增值税处理口径。示例只计算个人所得税,不计算增值税、附加税费、平台服务费或“个人保证到手价”的反算。
如果平台余额已经构成取得收入,不能因为用户尚未提现就一律延后计税。业务系统应明确收入确认事件;文章中的 paid_at 是该确认时间,不能随意改成回调接收时间或代码执行时间。
二、累计预扣如何变成逐笔计算
1. 先算累计目标,再减已经实际扣过的税
2025 年第 16 号公告规定的平台劳务累计预扣,包含累计收入的 20% 费用,以及按连续收入月份计算的每月 5,000 元减除费用,自 2025 年 10 月 1 日起施行。完整公式还包含免税收入、依法确定的其他扣除和减免税额。参见浙江税务对第 16 号公告的说明。
为了让示例可以独立运行,先限定:无免税收入、无其他法定扣除、无减免税额。这些字段在示例中视为零,有特殊事项的业务必须扩展规则和凭证模型,不能静默套用简化公式。
累计应纳税所得额 = max(连续段累计收入 × 80% - 5,000 × 连续月份数, 0)
累计目标税额 = 累计应纳税所得额 × 预扣率 - 速算扣除数
本笔预扣额 = max(累计目标税额 - 本连续段累计实际已预扣额, 0)
本笔实收额 = 本笔劳务收入 - 本笔预扣额
这里的“连续段”是本文用于实现的状态名称。对每笔收入重新求累计目标,差额就是本笔需要增加扣下的税。月底汇总这些实际扣税流水,不再对月收入额完整扣一遍税。
累计预扣使用的七级预扣率及速算扣除数如下,金额单位为元。第 16 号公告引用累计预扣法,税率表见国家税务总局对 2018 年第 61 号公告的解读。
| 累计应纳税所得额 | 预扣率 | 速算扣除数 |
|---|---|---|
| 不超过 36,000 | 3% | 0 |
| 超过 36,000,至 144,000 | 10% | 2,520 |
| 超过 144,000,至 300,000 | 20% | 16,920 |
| 超过 300,000,至 420,000 | 25% | 31,920 |
| 超过 420,000,至 660,000 | 30% | 52,920 |
| 超过 660,000,至 960,000 | 35% | 85,920 |
| 超过 960,000 | 45% | 181,920 |
2. 同月多笔,只增加收入,不增加月份
假设某人首次在 2026 年 6 月取得收入:
| 事件 | 连续月份 | 段累计收入 | 累计目标税额 | 本笔预扣 | 本笔实收 |
|---|---|---|---|---|---|
| 6 月第一笔 3,000 元 | 1 | 3,000 | 0 | 0 | 3,000 |
| 6 月第二笔 4,000 元 | 1 | 7,000 | 18 | 18 | 3,982 |
| 7 月第一笔 1,000 元 | 2 | 8,000 | 0 | 0 | 1,000 |
| 7 月第二笔 6,500 元 | 2 | 14,500 | 48 | 30 | 6,470 |
7 月第一笔发生时,新增了当月的 5,000 元减除费用,累计目标暂时降到零。6 月实际扣下的 18 元仍然保留,不能把已扣税状态改成零,否则 7 月第二笔会再次多扣。
累计预扣结果为负时暂不退税,年度终了再按规定处理。这也意味着实时预扣服务不能把负数直接当成平台向个人退款的指令。参见2018 年第 61 号公告第六条。
3. 中断月份与跨年都需要重新起算
官方解读中的例子包含 8、9 月没有收入、10 月重新累计的情形。按照该例的金额,换用 2026 年作为实现测试数据,结果如下:
| 收入月份 | 当月收入/元 | 连续段 | 月份数 | 本月实际预扣/元 |
|---|---|---|---|---|
| 6 月 | 7,000 | 第一段 | 1 | 18.00 |
| 7 月 | 7,500 | 第一段 | 2 | 30.00 |
| 8、9 月 | 无收入 | 中断 | — | 0 |
| 10 月 | 7,200 | 第二段 | 1 | 22.80 |
| 11 月 | 6,000 | 第二段 | 2 | 0 |
| 12 月 | 8,000 | 第二段 | 3 | 36.00 |
全年收入合计 35,700 元,平台实际预扣合计 106.80 元。第二段 11 月的累计目标是 16.80 元,低于此前实扣的 22.80 元;12 月的目标是 58.80 元,所以再扣 36 元。数据依据见官方解读第三问例 1。
实现时有三条状态转移规则:
- 仍在同一个月:月份数不变。
- 进入同年紧邻的下个月:月份数加一。
- 跳过无收入月,或进入下一纳税年度:新建连续段,段收入和段已扣税重新累计。
跨段清零的是当前连续段状态,历史流水和全年已扣税汇总都要保留。不能直接使用 time.Now().Month() 作为连续月份数,也不能把开户月份到当前的所有自然月都算进去。
4. 平台劳务预扣不要直接套工资扣除项
专项附加扣除不能因为使用了七级税率就直接照搬到此处。浙江税务的专项附加扣除问答明确引用实施条例:居民个人的劳务报酬等所得,在汇算清缴时提供有关信息减除专项附加扣除。
因此本文把个人年度扣除放到年度估算接口中;平台实时预扣端只执行已核准的劳务规则。
三、系统如何分工
收入确认事件
→ 身份与所得分类
→ 逐笔计税事务:查幂等 → 锁账户 → 算差额 → 写账本和月汇总
→ 返回本笔税款与实收
月度:核对账本 → 冻结月度快照 → 生成申报版本 → 跟踪申报及缴款回执
年度:汇总本平台全年数据 → 提供个人核对 → 全口径综合所得汇算估算
实时计算与法定申报应分开设计。申报、资金支付和税款缴库都有外部结果,不能把“本地计算成功”统一标为“已完税”。
本文直接给出两部分内容,全部代码都在正文中:
- 完整 Go 实现:标准库 HTTP 服务、内存账本、实时计税、月结、年度汇总与汇算估算,便于复现公式和接口。
- MySQL 生产存储设计:展示持久化表与事务边界。示例尚未接入 MySQL、银行和税务系统;下面会明确存储迁移需要完成的工作。
四、表设计:把流水、当前状态和申报版本分开
1. 确定累计口径和自然人身份
使用 scope_id 表示经核准的“本互联网平台企业”累计口径,并记录实际扣缴主体。它是服务端配置,不能允许客户端随意传一个商户号来改变累计范围。
同一自然人在相同累计口径下的多个用户账号,必须映射到同一个 taxpayer_id。同样,不能把集团内全部企业无条件合并成一个累计主体。结算运营主体变化时,需要先确认应如何延续计税数据。
2. 累计公式中的每个参数存在哪里
你可能更熟悉工资薪金的这一组累计参数:
累计收入 - 累计免税收入 - 累计减除费用
- 累计专项扣除 - 累计专项附加扣除 - 累计依法确定的其他扣除
这组字段可以保留在统一计税模型里,但所得类型和适用阶段决定哪些字段可以参与计算。对本文的平台劳务预扣,还要扣除累计收入的 20% 费用。不能因为库里有“专项附加扣除”列,就允许调用方把房租等任意填进实时扣税请求。
按以下方式映射,表中金额单位为分;费用和应纳税所得额使用 ×100 字段保留不足一分的精度:
| 公式参数 | 年度账户/逐笔快照字段 | Go 参数 | 取值来源与本文平台劳务预扣规则 |
|---|---|---|---|
| 累计收入 | segment_gross_cents | IncomeCents | 本口径、本人、当年当前连续段的有效收入流水累计 |
| 累计费用 | cumulative_expense_cents_x100 | ExpenseCents100 | 平台劳务按段收入的 20% 自动算;放大值为收入分乘 20 |
| 累计免税收入 | cumulative_exempt_cents | ExemptCents | 经核验、适用当前段的免税收入;本文 HTTP 实现设零 |
| 累计减除费用 | cumulative_basic_deduction_cents | BasicDeductionCents | 连续有收入的月份数乘 500000 分,服务端生成 |
| 累计专项扣除 | cumulative_special_deduction_cents | SpecialDeductionCents | 统一模型保留;本文平台劳务预扣设零,年度按适用条件处理 |
| 累计专项附加扣除 | cumulative_additional_deduction_cents | AdditionalDeductionCents | 本文平台劳务预扣设零,个人年度汇算时按规定处理 |
| 累计依法确定的其他扣除 | cumulative_other_deduction_cents | OtherDeductionCents | 从有证据、有期间和规则依据的合法扣除事实生成;本文设零 |
| 累计减免税额 | cumulative_relief_cents | ReliefCents | 算出税额后再减,不能混入应纳税所得额扣除;本文设零 |
| 累计已预扣预缴税额 | 账户 segment_withheld_cents;流水扣税前值 prior_withheld_cents | AlreadyWithheldCents | 历史实际已扣之和,不是历史理论目标税额 |
| 累计应纳税所得额 | 流水 taxable_cents_x100 | 结果 TaxableCents100 | 按已适用的参数派生,低于零取零 |
tax_account 保存当前连续段计算后的最新累计值;commission_ledger 保存每笔计算时的完整参数快照。发生中断时,新段全部重新构造;旧段流水保留。月度收入可以按逐笔金额求和,累计扣除参数应取月末生效快照,不能把每笔的累计值再相加。
tax_deduction_item 用来保存来源事实:纳税人、税年、月份或年度期间、预扣/年度阶段、所得类型、扣除类别、金额、依据、审核状态和规则版本。只有审核通过且适用于本次口径的记录才参与归集。5,000 元基本减除费用和 20% 费用由规则计算,不把它们伪造成用户上传的扣除凭证。
同一个扣除来源被重试提交时依赖唯一键去重。年度汇算按全年合法扣除总额重新计算,预扣环节已享受部分仍应包含在该全年总额中;不能在全年合计之外再次叠加同一项扣除。当前劳务 HTTP 入口不接收免税或扣除金额,这些预留字段不等于已经实现复杂事项的核验流程。
表中的唯一键只处理同口径、同阶段、同期间的来源重试。年度汇算还需核对跨平台、预扣与年度、月度与年度的来源归并,不能直接把所有 PREWITHHOLD 和 ANNUAL 记录相加。
如果以后扩展工资计税,需要将所得类型纳入账户隔离和规则选择,不能把工资与平台劳务直接累计到同一个连续段,也不能只改几项金额就绕过所得分类。
3. 佣金时间如何确定所属月份
| 字段 | 含义 | 是否决定计税所属月份 |
|---|---|---|
commission_at | 业务产生并确认该笔佣金的时间 | 不单独决定;若该确认本身已构成取得收入,应将同一事实记为 paid_at |
paid_at | 个人实际取得本笔收入的确认时间 | 是,按北京时间换算所属月和税年 |
created_at/接口 booked_at | 服务端写入账本的时间 | 否,重试或延迟入账不能改变原收入期间 |
tax_month、tax_year | 从 paid_at 派生的所属期间 | 服务端生成,客户端不能单独指定 |
例如佣金在 2026-12-31 产生,个人在 2027-01-02 才实际取得收入,在本文假设下属于 2027 年 1 月,不能因为佣金产生于上一年就计入 2026 年。反过来,7 月才收到系统回调的一笔 6 月已取得收入,仍不能直接按 7 月入账,应进入前文约定的补录/更正流程。
本文代码要求两个业务时间都提供,且 commission_at 不晚于 paid_at;booked_at 由服务端生成。该先后约束是本文对“佣金生成后取得收入”流程的建模,其他业务事件定义需要单独调整。时间戳统一存 UTC;月份统一按 Asia/Shanghai 计算。
MySQL 的 DATETIME(6) 只保留微秒,Go 时间可以包含纳秒。持久化适配器应先统一时间精度,再进行请求摘要、幂等比较和顺序校验,避免保存后精度变化导致同一事件重试不一致。
4. 各张表负责什么
| 表 | 主键或关键唯一键 | 作用 |
|---|---|---|
tax_scope | 口径 ID | 记录平台、扣缴主体和规则版本 |
taxpayer | 内部自然人 ID;身份摘要唯一 | 合并同一证件,保存实名核验关联信息 |
platform_user_taxpayer | 口径+平台用户 ID | 将多个业务账号映射到同一自然人 |
tax_period | 口径+月份 | 控制整个平台月份的开放与冻结 |
tax_deduction_item | 来源+所属期间+适用阶段等唯一 | 保存经核验的免税、扣除和减免事实 |
tax_account | 口径+自然人+年度 | 锁定实时计算状态,保存当前连续段 |
commission_ledger | 口径+业务流水号唯一 | 不可变逐笔收入、税款、实收及计算快照 |
tax_month | 口径+自然人+月份 | 月累计和冻结状态 |
tax_declaration | 口径+月份+修订号 | 月申报快照、提交状态与回执 |
tax_outbox | 事件 ID;业务事件键唯一 | 与账本同事务写入的待投递事件 |
annual_snapshot | 口径+自然人+年度+修订号 | 平台全年汇总的可核对版本 |
完整 DDL 直接列在下方。所有金额用 BIGINT 存人民币分;收入、税款和实收分列,并校验 gross_cents = withheld_cents + net_cents。业务号、身份摘要等唯一键使用区分大小写的约定,避免两个业务号被数据库排序规则意外视为相同。
tax_account 至少需要这些状态:
scope_id, taxpayer_id, tax_year
last_paid_at, last_month
segment_start_month, segment_months
segment_gross_cents, segment_withheld_cents
version
commission_ledger 应保存输入摘要、规则版本、所属月、段起始月、段累计收入、累计目标税额、扣税后的段累计实扣和本笔结果。同时单列 prior_withheld_cents 保存扣税前的实扣,便于逐项审计。这样查询“这笔为什么扣 30 元”时,无需猜测当时规则或依赖已经变化的账户状态。
下面是完整的 MySQL 8.0.16+ 表结构,按外键依赖顺序创建。DDL 是生产适配草案,本次未连接 MySQL 实例执行导入:
-- MySQL 8.0.16+:生产持久化设计草案;Go 教学服务使用内存,不会执行此文件。
-- 连接会话应 SET time_zone = '+00:00';所有 DATETIME(6) 均保存 UTC。
-- tax_month 在入账前按 Asia/Shanghai 计算,格式 YYYY-MM。
-- 金额均为人民币分,*_x100 为「分的百分之一」;不要改为 FLOAT/DOUBLE。
-- 本例单人金额上限为 1,000,000,000,000 分,单人的段/月/年聚合也应校验上限。
CREATE TABLE tax_scope (
scope_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
platform_name VARCHAR(200) NOT NULL,
platform_tax_id VARCHAR(40) NOT NULL,
withholding_agent_tax_id VARCHAR(40) NOT NULL,
rule_version VARCHAR(64) CHARACTER SET ascii NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='经业务及税务确认的平台累计口径;由服务端选择';
CREATE TABLE taxpayer (
taxpayer_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
identity_token BINARY(32) NOT NULL,
identity_ciphertext VARBINARY(2048) NOT NULL,
verified_at DATETIME(6) NULL,
verification_reference VARCHAR(200) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE KEY uq_taxpayer_identity (identity_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='identity_token为规范化证件标识的带密钥HMAC;证件原文加密';
CREATE TABLE platform_user_taxpayer (
scope_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
platform_user_id VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
taxpayer_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (scope_id, platform_user_id),
KEY idx_scope_taxpayer (scope_id, taxpayer_id),
FOREIGN KEY (scope_id) REFERENCES tax_scope (scope_id),
FOREIGN KEY (taxpayer_id) REFERENCES taxpayer (taxpayer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同平台多个账号归并到同一真实纳税人';
CREATE TABLE tax_deduction_item (
item_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
scope_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
taxpayer_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
tax_year SMALLINT UNSIGNED NOT NULL,
period_key VARCHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
apply_phase ENUM('PREWITHHOLD', 'ANNUAL') NOT NULL,
income_type ENUM('PLATFORM_LABOR', 'WAGES', 'COMPREHENSIVE') NOT NULL,
item_type ENUM('EXEMPT', 'SPECIAL', 'ADDITIONAL', 'OTHER', 'RELIEF') NOT NULL,
amount_cents BIGINT NOT NULL,
source_ref VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
evidence_ref VARCHAR(200) NOT NULL,
rule_version VARCHAR(64) CHARACTER SET ascii NOT NULL,
status ENUM('DRAFT', 'APPROVED', 'REVOKED') NOT NULL DEFAULT 'DRAFT',
approved_at DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE KEY uq_deduction_source (scope_id, taxpayer_id, apply_phase, period_key, item_type, source_ref),
KEY idx_deduction_period (scope_id, taxpayer_id, tax_year, apply_phase, status),
FOREIGN KEY (scope_id) REFERENCES tax_scope (scope_id),
FOREIGN KEY (taxpayer_id) REFERENCES taxpayer (taxpayer_id),
CHECK (tax_year BETWEEN 2025 AND 9999),
CHECK (amount_cents BETWEEN 0 AND 1000000000000),
CHECK (period_key REGEXP '^[0-9]{4}(-(0[1-9]|1[0-2]))?$'),
CHECK (LEFT(period_key, 4) = CAST(tax_year AS CHAR)),
CHECK (apply_phase <> 'PREWITHHOLD' OR CHAR_LENGTH(period_key) = 7),
CHECK (NOT (apply_phase = 'PREWITHHOLD' AND income_type = 'PLATFORM_LABOR'
AND item_type IN ('SPECIAL', 'ADDITIONAL'))),
CHECK (status <> 'APPROVED' OR approved_at IS NOT NULL)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='经核验的扣除/减免事实;按来源去重,按适用阶段和所得分类读取';
CREATE TABLE tax_period (
scope_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
tax_month CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status ENUM('OPEN', 'CLOSED') NOT NULL DEFAULT 'OPEN',
version BIGINT UNSIGNED NOT NULL DEFAULT 0,
closed_at DATETIME(6) NULL,
PRIMARY KEY (scope_id, tax_month),
FOREIGN KEY (scope_id) REFERENCES tax_scope (scope_id),
CHECK (tax_month REGEXP '^[0-9]{4}-(0[1-9]|1[0-2])$'),
CHECK ((status = 'OPEN' AND closed_at IS NULL) OR
(status = 'CLOSED' AND closed_at IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台整月关账门闩;须先初始化,再在入账事务中FOR SHARE锁定';
CREATE TABLE tax_account (
scope_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
taxpayer_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
tax_year SMALLINT UNSIGNED NOT NULL,
last_paid_at DATETIME(6) NULL,
last_month CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NULL,
segment_start_month CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NULL,
segment_months TINYINT UNSIGNED NOT NULL DEFAULT 0,
segment_gross_cents BIGINT NOT NULL DEFAULT 0,
cumulative_expense_cents_x100 BIGINT NOT NULL DEFAULT 0,
cumulative_exempt_cents BIGINT NOT NULL DEFAULT 0,
cumulative_basic_deduction_cents BIGINT NOT NULL DEFAULT 0,
cumulative_special_deduction_cents BIGINT NOT NULL DEFAULT 0,
cumulative_additional_deduction_cents BIGINT NOT NULL DEFAULT 0,
cumulative_other_deduction_cents BIGINT NOT NULL DEFAULT 0,
cumulative_relief_cents BIGINT NOT NULL DEFAULT 0,
segment_withheld_cents BIGINT NOT NULL DEFAULT 0,
version BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (scope_id, taxpayer_id, tax_year),
FOREIGN KEY (scope_id) REFERENCES tax_scope (scope_id),
FOREIGN KEY (taxpayer_id) REFERENCES taxpayer (taxpayer_id),
CHECK (tax_year BETWEEN 2025 AND 9999),
CHECK (segment_months BETWEEN 0 AND 12),
CHECK (cumulative_expense_cents_x100 BETWEEN 0 AND segment_gross_cents * 100),
CHECK (cumulative_exempt_cents BETWEEN 0 AND 1000000000000),
CHECK (cumulative_basic_deduction_cents BETWEEN 0 AND 1000000000000),
CHECK (cumulative_special_deduction_cents BETWEEN 0 AND 1000000000000),
CHECK (cumulative_additional_deduction_cents BETWEEN 0 AND 1000000000000),
CHECK (cumulative_other_deduction_cents BETWEEN 0 AND 1000000000000),
CHECK (cumulative_relief_cents BETWEEN 0 AND 1000000000000),
CHECK (segment_gross_cents BETWEEN 0 AND 1000000000000),
CHECK (segment_withheld_cents BETWEEN 0 AND segment_gross_cents)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='年度账户中的当前连续收入段;中断只重置段,不删除流水';
CREATE TABLE commission_ledger (
ledger_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
scope_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
biz_id VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
taxpayer_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
tax_year SMALLINT UNSIGNED NOT NULL,
tax_month CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
commission_at DATETIME(6) NOT NULL,
paid_at DATETIME(6) NOT NULL,
request_hash BINARY(32) NOT NULL,
gross_cents BIGINT NOT NULL,
withheld_cents BIGINT NOT NULL,
net_cents BIGINT NOT NULL,
segment_start_month CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
segment_months TINYINT UNSIGNED NOT NULL,
segment_gross_cents BIGINT NOT NULL,
cumulative_expense_cents_x100 BIGINT NOT NULL DEFAULT 0,
cumulative_exempt_cents BIGINT NOT NULL DEFAULT 0,
cumulative_basic_deduction_cents BIGINT NOT NULL DEFAULT 0,
cumulative_special_deduction_cents BIGINT NOT NULL DEFAULT 0,
cumulative_additional_deduction_cents BIGINT NOT NULL DEFAULT 0,
cumulative_other_deduction_cents BIGINT NOT NULL DEFAULT 0,
cumulative_relief_cents BIGINT NOT NULL DEFAULT 0,
taxable_cents_x100 BIGINT NOT NULL,
rate_percent TINYINT UNSIGNED NOT NULL,
quick_deduction_cents BIGINT NOT NULL,
target_tax_cents BIGINT NOT NULL,
prior_withheld_cents BIGINT NOT NULL,
segment_withheld_cents BIGINT NOT NULL,
account_version BIGINT UNSIGNED NOT NULL,
rule_version VARCHAR(64) CHARACTER SET ascii NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE KEY uq_scope_biz (scope_id, biz_id),
KEY idx_taxpayer_month (scope_id, taxpayer_id, tax_month, ledger_id),
KEY idx_taxpayer_paid_at (scope_id, taxpayer_id, paid_at, ledger_id),
FOREIGN KEY (scope_id, taxpayer_id, tax_year)
REFERENCES tax_account (scope_id, taxpayer_id, tax_year),
FOREIGN KEY (scope_id, tax_month) REFERENCES tax_period (scope_id, tax_month),
CHECK (gross_cents BETWEEN 1 AND 1000000000000),
CHECK (withheld_cents BETWEEN 0 AND gross_cents),
CHECK (net_cents = gross_cents - withheld_cents),
CHECK (segment_months BETWEEN 1 AND 12),
CHECK (cumulative_expense_cents_x100 BETWEEN 0 AND segment_gross_cents * 100),
CHECK (cumulative_exempt_cents BETWEEN 0 AND 1000000000000),
CHECK (cumulative_basic_deduction_cents BETWEEN 0 AND 1000000000000),
CHECK (cumulative_special_deduction_cents BETWEEN 0 AND 1000000000000),
CHECK (cumulative_additional_deduction_cents BETWEEN 0 AND 1000000000000),
CHECK (cumulative_other_deduction_cents BETWEEN 0 AND 1000000000000),
CHECK (cumulative_relief_cents BETWEEN 0 AND 1000000000000),
CHECK (commission_at <= paid_at),
CHECK (prior_withheld_cents >= 0),
CHECK (segment_withheld_cents = prior_withheld_cents + withheld_cents),
CHECK (segment_gross_cents BETWEEN gross_cents AND 1000000000000),
CHECK (taxable_cents_x100 BETWEEN 0 AND 100000000000000),
CHECK (rate_percent IN (3, 10, 20, 25, 30, 35, 45)),
CHECK (quick_deduction_cents >= 0 AND target_tax_cents >= 0),
CHECK (segment_withheld_cents BETWEEN withheld_cents AND segment_gross_cents)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='已确认收入及预扣的不可变流水;生产账号应禁止UPDATE/DELETE';
CREATE TABLE tax_month (
scope_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
taxpayer_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
tax_month CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
gross_cents BIGINT NOT NULL DEFAULT 0,
withheld_cents BIGINT NOT NULL DEFAULT 0,
net_cents BIGINT NOT NULL DEFAULT 0,
commission_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
status ENUM('OPEN', 'CLOSED') NOT NULL DEFAULT 'OPEN',
version BIGINT UNSIGNED NOT NULL DEFAULT 0,
snapshot_hash BINARY(32) NULL,
closed_at DATETIME(6) NULL,
PRIMARY KEY (scope_id, taxpayer_id, tax_month),
FOREIGN KEY (scope_id) REFERENCES tax_scope (scope_id),
FOREIGN KEY (taxpayer_id) REFERENCES taxpayer (taxpayer_id),
FOREIGN KEY (scope_id, tax_month) REFERENCES tax_period (scope_id, tax_month),
CHECK (gross_cents BETWEEN 0 AND 1000000000000),
CHECK (withheld_cents BETWEEN 0 AND gross_cents),
CHECK (net_cents = gross_cents - withheld_cents),
CHECK ((status = 'OPEN' AND closed_at IS NULL) OR
(status = 'CLOSED' AND closed_at IS NOT NULL AND snapshot_hash IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='可由流水重建的月汇总;关闭后原版本不再变更';
CREATE TABLE tax_declaration (
declaration_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
scope_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
tax_month CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
revision INT UNSIGNED NOT NULL,
snapshot_hash BINARY(32) NOT NULL,
gross_cents BIGINT NOT NULL,
withheld_cents BIGINT NOT NULL,
status ENUM('DRAFT', 'EXPORTED', 'SUBMITTED', 'ACCEPTED', 'REJECTED') NOT NULL,
payment_status ENUM('UNPAID', 'PROCESSING', 'PAID', 'FAILED') NOT NULL DEFAULT 'UNPAID',
receipt_reference VARCHAR(200) NULL,
submitted_at DATETIME(6) NULL,
accepted_at DATETIME(6) NULL,
paid_at DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE KEY uq_declaration_revision (scope_id, tax_month, revision),
FOREIGN KEY (scope_id, tax_month) REFERENCES tax_period (scope_id, tax_month),
CHECK (revision >= 1),
CHECK (gross_cents >= 0 AND withheld_cents BETWEEN 0 AND gross_cents)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='按版本保留导出及申报缴款回执;重试不能直接标记PAID';
CREATE TABLE tax_outbox (
event_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
scope_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
event_key VARCHAR(160) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
event_type VARCHAR(64) CHARACTER SET ascii NOT NULL,
payload JSON NOT NULL,
status ENUM('PENDING', 'SENT', 'FAILED') NOT NULL DEFAULT 'PENDING',
attempts INT UNSIGNED NOT NULL DEFAULT 0,
next_attempt_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
sent_at DATETIME(6) NULL,
UNIQUE KEY uq_event_key (scope_id, event_key),
KEY idx_outbox_dispatch (status, next_attempt_at, event_id),
FOREIGN KEY (scope_id) REFERENCES tax_scope (scope_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='与账本同事务写入事件;消费者需幂等并核查外部未知结果';
CREATE TABLE annual_snapshot (
scope_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
taxpayer_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
tax_year SMALLINT UNSIGNED NOT NULL,
revision INT UNSIGNED NOT NULL,
gross_cents BIGINT NOT NULL,
withheld_cents BIGINT NOT NULL,
net_cents BIGINT NOT NULL,
commission_count BIGINT UNSIGNED NOT NULL,
snapshot_hash BINARY(32) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (scope_id, taxpayer_id, tax_year, revision),
FOREIGN KEY (scope_id) REFERENCES tax_scope (scope_id),
FOREIGN KEY (taxpayer_id) REFERENCES taxpayer (taxpayer_id),
CHECK (tax_year BETWEEN 2025 AND 9999 AND revision >= 1),
CHECK (gross_cents BETWEEN 0 AND 1000000000000),
CHECK (withheld_cents BETWEEN 0 AND gross_cents),
CHECK (net_cents = gross_cents - withheld_cents)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本平台年度预扣汇总版本,不是个人全口径综合所得汇算结论';
身份证号、姓名和银行卡不应散落在每笔佣金流水中。身份资料单独加密保存,业务表使用内部 ID;稳定身份摘要应采用带密钥的摘要并管理密钥版本,不能只对证件号做普通哈希。
5. 月度汇总可以更新,已确认流水要留痕
tax_month 用于在线查询,但不能成为唯一的事实来源。月结时从 commission_ledger 独立求和,与月表核对收入、税款、实收和笔数,再冻结。
SELECT taxpayer_id,
SUM(gross_cents) AS gross_cents,
SUM(withheld_cents) AS withheld_cents,
SUM(net_cents) AS net_cents,
COUNT(*) AS entry_count
FROM commission_ledger
WHERE scope_id = ? AND tax_month = ?
GROUP BY taxpayer_id;
月结不能重复扣税,冻结也不等于申报成功。更正需要生成新修订、保留旧快照与回执,不能直接把已申报的历史行覆盖掉。
五、接口设计:计算、汇总和汇算各有入口
配套示例固定使用服务端演示口径 demo-platform。生产环境从认证上下文取得口径和权限,按对象检查纳税人归属;不能只凭 URL 中的 ID 就允许查询个人收入。
| 方法与路径 | 用途 | 是否写入 |
|---|---|---|
POST /v1/commissions | 确认一笔收入并返回本笔预扣结果 | 是,业务号幂等 |
GET /v1/taxpayers/{id}/months/{YYYY-MM} | 查询本月收入、预扣、实收、笔数 | 否 |
POST /v1/taxpayers/{id}/months/{YYYY-MM}/close | 核对并冻结该人的月度结果 | 是,重复调用返回相同月结结果 |
GET /v1/taxpayers/{id}/years/{YYYY} | 查询平台全年汇总,包括已扣税总额 | 否 |
POST /v1/annual-estimates | 根据个人完整综合所得数据估算退补差额 | 否,不提交税务申报 |
POST /v1/commissions 是收入确认接口,不能在页面打开、预览报价或每次支付重试时生成新业务号。一次业务事件使用长期稳定的 biz_id:
{
"biz_id": "commission-202606-0001",
"taxpayer_id": "person-1001",
"commission_at": "2026-06-03T09:30:00+08:00",
"paid_at": "2026-06-03T10:00:00+08:00",
"gross_cents": 300000
}
同一口径内相同业务号、相同输入返回原结果;相同业务号却改变个人、金额、佣金生成时间或收入取得时间,返回 409。幂等检查应早于已冻结、倒序时间等检查,让已成功请求在月结后仍能正确重试。
示例只接受正数收入和有序确认事件,收入年度限定 2026—2100 年,拒绝已冻结月份及倒序时间。错误输入返回 400,业务冲突返回 409,不存在的汇总返回 404,聚合金额超限等返回 422。这使教学实现能保持清晰;生产系统的历史补录、退款与税务更正要走专门流程。
所有接口金额均为整数分;时间必须携带时区。月度归属按 Asia/Shanghai 计算,数据库时间戳建议统一存 UTC。例如 2026-06-30T16:30:00Z 属于北京时间 7 月,不能按字符串前七位分组。
六、Go 实现:金额精度和状态转移
以下使用 Go 1.22+ 和标准库。按正文代码块标题创建文件,目录结构如下,后文会继续补全年度计算、HTTP 服务和测试;不需要下载额外源码。
commission-tax/
go.mod
tax.go
annual.go
ledger.go
server.go
tax_test.go
ledger_test.go
server_test.go
cmd/server/main.go
module commissiontax.local
go 1.22
1. 金额使用整数,累计税额只在最后舍入
金额字段用 int64 分,不使用 float64。但是 1 分 × 80% 会产生不足一分的结果,所以中间计算还需要保留精度,不能先把每笔费用四舍五入后再相加。
示例把应纳税所得额保存为“分的百分之一”的整数,即实际分值乘以 100:
所得额放大值 = max(累计收入分 × 80 - 500000分 × 月份数 × 100, 0)
税额分 = 四舍五入((所得额放大值 × 税率百分数 - 速扣分 × 10000) / 10000)
只对累计目标税额舍入,然后减已经实际扣下的整数分税款。这种计算顺序让同月拆成多笔后,月末累计结果保持一致。
舍入方式是本文示例的工程约定,公告没有为此示例指定每个中间步骤如何取舍。正式对接时应和实际申报系统校验分位差异,并将取舍规则纳入 rule_version。示例对单笔和聚合金额设置上限,避免整数乘法溢出;扩大金额范围时必须重新检查中间乘积。
先创建 tax.go。CumulativeParams 对应前面的逐项字段,CalculateCumulative 计算完整参数下的应纳税所得额、累计目标税额和本笔增量;这些参数须由已核准的服务端规则生成。CumulativeTax 则是本文平台劳务简化场景的包装,不会因为底层公式支持专项附加扣除就对外开放任意扣除:
// Package commissiontax demonstrates an in-memory commission tax ledger.
// It neither transfers money nor submits tax returns.
package commissiontax
import "fmt"
// MaxMoney bounds both individual amounts and their aggregates in cents.
// This keeps integer intermediate products safely inside int64.
const MaxMoney int64 = 1_000_000_000_000
const RuleVersion = "platform-2025-16-basic-v1"
type bracket struct {
ceiling int64 // cents of taxable income; numerator below uses 1/100 cent
rate int64 // percent
quick int64 // cents
}
var brackets = [...]bracket{
{3_600_000, 3, 0},
{14_400_000, 10, 252_000},
{30_000_000, 20, 1_692_000},
{42_000_000, 25, 3_192_000},
{66_000_000, 30, 5_292_000},
{96_000_000, 35, 8_592_000},
{MaxMoney, 45, 18_192_000},
}
func validMoney(v int64) bool { return v >= 0 && v <= MaxMoney }
// taxOnBase receives taxable income in units of 1/100 cent. Preserve the
// fractions introduced by 80% and 56%, then round only the final tax to cents.
func taxOnBase(base100 int64) int64 {
if base100 <= 0 {
return 0
}
for _, b := range brackets {
if base100 <= b.ceiling*100 {
return (base100*b.rate - b.quick*10_000 + 5_000) / 10_000
}
}
panic("taxOnBase: caller exceeded MaxMoney")
}
// CumulativeParams is trusted input to the general cumulative formula, not
// permission to claim a deduction. Each deduction's applicability must first be
// verified for the relevant income/policy. The platform HTTP request cannot set
// these fields; Ledger builds its own basic-case parameters on the server.
// All fields use cents except ExpenseCents100, which uses 1/100 cent.
type CumulativeParams struct {
IncomeCents int64 `json:"income_cents"`
ExpenseCents100 int64 `json:"expense_cents_x100"`
ExemptCents int64 `json:"exempt_cents"`
BasicDeductionCents int64 `json:"basic_deduction_cents"`
SpecialDeductionCents int64 `json:"special_deduction_cents"`
AdditionalDeductionCents int64 `json:"additional_deduction_cents"`
OtherDeductionCents int64 `json:"other_deduction_cents"`
ReliefCents int64 `json:"relief_cents"`
AlreadyWithheldCents int64 `json:"already_withheld_cents"`
}
type CumulativeResult struct {
TaxableCents100 int64 `json:"taxable_cents_x100"`
TargetTaxCents int64 `json:"target_tax_cents"`
WithheldCents int64 `json:"withheld_cents"`
}
// CalculateCumulative preserves fractional cents until rounding target tax.
// Rounding to cents is an engineering convention to reconcile with the actual
// filing system before production use, not a new rule stated by the notice.
func CalculateCumulative(p CumulativeParams) (CumulativeResult, error) {
for _, value := range []int64{p.IncomeCents, p.ExemptCents, p.BasicDeductionCents,
p.SpecialDeductionCents, p.AdditionalDeductionCents, p.OtherDeductionCents,
p.ReliefCents, p.AlreadyWithheldCents} {
if !validMoney(value) {
return CumulativeResult{}, fmt.Errorf("cumulative amounts must be between 0 and %d cents", MaxMoney)
}
}
if p.ExpenseCents100 < 0 || p.ExpenseCents100 > MaxMoney*100 {
return CumulativeResult{}, fmt.Errorf("expense_cents_x100 must be between 0 and %d", MaxMoney*100)
}
if p.ExpenseCents100 > p.IncomeCents*100 {
return CumulativeResult{}, fmt.Errorf("expense_cents_x100 cannot exceed income_cents*100")
}
// At these field bounds, even the sum of all deduction intermediates fits
// int64. Nonnegative deductions also keep the taxable base <= MaxMoney*100.
deductions := p.ExemptCents + p.BasicDeductionCents + p.SpecialDeductionCents +
p.AdditionalDeductionCents + p.OtherDeductionCents
base100 := p.IncomeCents*100 - p.ExpenseCents100 - deductions*100
if base100 < 0 {
base100 = 0
}
target := taxOnBase(base100) - p.ReliefCents
if target < 0 {
target = 0
}
withheld := target - p.AlreadyWithheldCents
if withheld < 0 {
withheld = 0
}
return CumulativeResult{base100, target, withheld}, nil
}
// CumulativeTax wraps the general formula for one platform continuous segment:
// expense = income*20%; basic deduction = RMB 5,000*continuous months;
// exempt income, other deductions, relief and already-withheld tax are zero.
func CumulativeTax(grossCents int64, months int) (int64, error) {
if !validMoney(grossCents) || months < 1 || months > 12 {
return 0, fmt.Errorf("invalid gross or continuous month count")
}
result, err := CalculateCumulative(CumulativeParams{
IncomeCents: grossCents,
ExpenseCents100: grossCents * 20,
BasicDeductionCents: int64(months) * 500_000,
})
return result.TargetTaxCents, err
}
2. 实际扣税在同一个临界区内完成
单纯写一个 CalculateTax(amount) 不够。两笔并发佣金如果同时读到“此前已扣 18 元”,都可能扣掉相同的增量。
Ledger.Post 将幂等检查、连续段切换、目标计算、流水写入和月汇总更新放在同一个互斥锁中。Month、CloseMonth 和 Year 分别实现月度查询、从流水核对后冻结、平台年度汇总。下面是完整 ledger.go,包含所需结构体和辅助函数:
package commissiontax
import (
"fmt"
"regexp"
"sync"
"time"
)
var safeID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60)
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (e *Error) Error() string { return e.Message }
func fail(code, message string) error { return &Error{code, message} }
type CommissionRequest struct {
BizID string `json:"biz_id"`
TaxpayerID string `json:"taxpayer_id"`
CommissionAt time.Time `json:"commission_at"`
PaidAt time.Time `json:"paid_at"`
GrossCents int64 `json:"gross_cents"`
}
type Commission struct {
CommissionRequest
Scope string `json:"scope"`
Sequence int64 `json:"sequence"`
TaxMonth string `json:"tax_month"`
SegmentStart string `json:"segment_start"`
ContinuousMonths int `json:"continuous_months"`
SegmentGrossCents int64 `json:"segment_gross_cents"`
Calculation CumulativeParams `json:"calculation"`
TaxableCents100 int64 `json:"taxable_cents_x100"`
TargetTaxCents int64 `json:"target_tax_cents"`
WithheldCents int64 `json:"withheld_cents"`
SegmentPaidCents int64 `json:"segment_paid_cents"`
NetCents int64 `json:"net_cents"`
RuleVersion string `json:"rule_version"`
BookedAt time.Time `json:"booked_at"`
}
type MonthSummary struct {
Scope string `json:"scope"`
TaxpayerID string `json:"taxpayer_id"`
TaxMonth string `json:"tax_month"`
Count int64 `json:"count"`
GrossCents int64 `json:"gross_cents"`
WithheldCents int64 `json:"withheld_cents"`
NetCents int64 `json:"net_cents"`
LastSequence int64 `json:"last_sequence"`
Closed bool `json:"closed"`
RuleVersion string `json:"rule_version"`
}
type YearSummary struct {
Scope string `json:"scope"`
TaxpayerID string `json:"taxpayer_id"`
Year int `json:"year"`
Count int64 `json:"count"`
GrossCents int64 `json:"gross_cents"`
WithheldCents int64 `json:"withheld_cents"`
NetCents int64 `json:"net_cents"`
ClosedMonths int `json:"closed_months"`
Status string `json:"status"`
}
type segmentState struct {
lastPaid time.Time
lastMonth string
start string
months int
gross int64
paid int64
}
// Ledger's mutex stands in for a database transaction solely for teaching.
// One Ledger has one server-selected tax scope; restarting loses all records.
type Ledger struct {
mu sync.Mutex
scope string
sequence int64
entries map[string]Commission
states map[string]segmentState // taxpayer; last event retained across years
months map[string]MonthSummary
years map[string]YearSummary
}
func NewLedger(scope string) *Ledger {
return &Ledger{scope: scope, entries: make(map[string]Commission), states: make(map[string]segmentState), months: make(map[string]MonthSummary), years: make(map[string]YearSummary)}
}
func monthKey(taxpayerID, month string) string { return taxpayerID + "/" + month }
func yearKey(taxpayerID string, year int) string { return fmt.Sprintf("%s/%04d", taxpayerID, year) }
func parseMonth(value string) (time.Time, error) {
t, err := time.ParseInLocation("2006-01", value, shanghai)
if err != nil || t.Format("2006-01") != value || t.Year() < 2026 || t.Year() > 2100 {
return time.Time{}, fail("invalid_request", "tax month must be YYYY-MM between 2026 and 2100")
}
return t, nil
}
func (l *Ledger) Post(req CommissionRequest) (Commission, error) {
if !safeID.MatchString(req.BizID) || !safeID.MatchString(req.TaxpayerID) || req.GrossCents <= 0 || req.GrossCents > MaxMoney {
return Commission{}, fail("invalid_request", "invalid ID or gross_cents (positive integer cents required)")
}
if req.CommissionAt.IsZero() || req.PaidAt.IsZero() || req.CommissionAt.After(req.PaidAt) {
return Commission{}, fail("invalid_request", "commission_at and paid_at are required; commission_at cannot be later than paid_at")
}
req.CommissionAt = req.CommissionAt.In(shanghai)
req.PaidAt = req.PaidAt.In(shanghai)
if req.PaidAt.Year() < 2026 || req.PaidAt.Year() > 2100 {
return Commission{}, fail("invalid_request", "demo paid_at must be in years 2026..2100")
}
l.mu.Lock()
defer l.mu.Unlock()
// Idempotency is checked before closed-period/ordering rules. A retry of an
// already committed event must return its original result even after close.
if old, ok := l.entries[req.BizID]; ok {
if old.TaxpayerID != req.TaxpayerID || old.GrossCents != req.GrossCents || !old.CommissionAt.Equal(req.CommissionAt) || !old.PaidAt.Equal(req.PaidAt) {
return Commission{}, fail("idempotency_conflict", "biz_id was already used with different data")
}
return old, nil
}
month := req.PaidAt.Format("2006-01")
mk := monthKey(req.TaxpayerID, month)
m := l.months[mk]
if m.Closed {
return Commission{}, fail("period_closed", "closed month requires a separate correction workflow")
}
s, exists := l.states[req.TaxpayerID]
if exists && req.PaidAt.Before(s.lastPaid) {
return Commission{}, fail("out_of_order", "paid_at precedes the last committed event")
}
if !exists || s.lastPaid.Year() != req.PaidAt.Year() {
s = segmentState{start: month, months: 1}
} else if month != s.lastMonth {
prev, _ := time.ParseInLocation("2006-01", s.lastMonth, shanghai)
if prev.AddDate(0, 1, 0).Format("2006-01") == month {
s.months++
} else {
s = segmentState{start: month, months: 1}
}
}
yk := yearKey(req.TaxpayerID, req.PaidAt.Year())
y := l.years[yk]
if s.gross+req.GrossCents > MaxMoney || y.GrossCents+req.GrossCents > MaxMoney {
return Commission{}, fail("amount_limit", "segment or annual aggregate exceeds demo amount limit")
}
s.gross += req.GrossCents
// These parameters come from the locked ledger state, not arbitrary HTTP
// deduction fields. Snapshot AlreadyWithheldCents BEFORE adding this event.
params := CumulativeParams{
IncomeCents: s.gross,
ExpenseCents100: s.gross * 20,
BasicDeductionCents: int64(s.months) * 500_000,
AlreadyWithheldCents: s.paid,
}
calculated, err := CalculateCumulative(params)
if err != nil {
return Commission{}, fail("invalid_request", err.Error())
}
withheld := calculated.WithheldCents
if withheld > req.GrossCents {
return Commission{}, fail("insufficient_gross", "tax exceeds this event's gross; manual handling required")
}
s.paid += withheld
s.lastPaid, s.lastMonth = req.PaidAt, month
l.sequence++
entry := Commission{
CommissionRequest: req,
Scope: l.scope,
Sequence: l.sequence,
TaxMonth: month,
SegmentStart: s.start,
ContinuousMonths: s.months,
SegmentGrossCents: s.gross,
Calculation: params,
TaxableCents100: calculated.TaxableCents100,
TargetTaxCents: calculated.TargetTaxCents,
WithheldCents: withheld,
SegmentPaidCents: s.paid,
NetCents: req.GrossCents - withheld,
RuleVersion: RuleVersion,
BookedAt: time.Now().UTC(),
}
// All validations finish before any mutation; these writes are one atomic
// critical section. Database variants must commit them in one transaction.
l.entries[req.BizID], l.states[req.TaxpayerID] = entry, s
m.Scope, m.TaxpayerID, m.TaxMonth, m.RuleVersion = l.scope, req.TaxpayerID, month, RuleVersion
m.Count++
m.GrossCents += req.GrossCents
m.WithheldCents += withheld
m.NetCents += entry.NetCents
m.LastSequence = entry.Sequence
l.months[mk] = m
y.Scope, y.TaxpayerID, y.Year, y.Status = l.scope, req.TaxpayerID, req.PaidAt.Year(), "platform_summary_only"
y.Count++
y.GrossCents += req.GrossCents
y.WithheldCents += withheld
y.NetCents += entry.NetCents
l.years[yk] = y
return entry, nil
}
func (l *Ledger) Month(taxpayerID, month string) (MonthSummary, error) {
if !safeID.MatchString(taxpayerID) {
return MonthSummary{}, fail("invalid_request", "invalid taxpayer ID")
}
if _, err := parseMonth(month); err != nil {
return MonthSummary{}, err
}
l.mu.Lock()
defer l.mu.Unlock()
m, ok := l.months[monthKey(taxpayerID, month)]
if !ok {
return MonthSummary{}, fail("not_found", "month has no committed records")
}
return m, nil
}
// CloseMonth freezes an existing month. It is an internal accounting action,
// not tax filing, payment confirmation or a tax-office receipt.
func (l *Ledger) CloseMonth(taxpayerID, month string) (MonthSummary, error) {
if !safeID.MatchString(taxpayerID) {
return MonthSummary{}, fail("invalid_request", "invalid taxpayer ID")
}
t, err := parseMonth(month)
if err != nil {
return MonthSummary{}, err
}
l.mu.Lock()
defer l.mu.Unlock()
k := monthKey(taxpayerID, month)
m, ok := l.months[k]
if !ok {
return MonthSummary{}, fail("not_found", "cannot close an empty month in this demo")
}
if !m.Closed {
// Rebuild independently from immutable event results. Do not recompute
// historical events using today's tax state or silently repair a mismatch.
var count, gross, withheld, net, lastSequence int64
for _, entry := range l.entries {
if entry.TaxpayerID != taxpayerID || entry.TaxMonth != month {
continue
}
count++
gross += entry.GrossCents
withheld += entry.WithheldCents
net += entry.NetCents
if entry.Sequence > lastSequence {
lastSequence = entry.Sequence
}
}
if count != m.Count || gross != m.GrossCents || withheld != m.WithheldCents || net != m.NetCents || lastSequence != m.LastSequence || gross != withheld+net {
return MonthSummary{}, fail("ledger_inconsistency", "monthly summary does not match committed ledger entries")
}
m.Closed = true
l.months[k] = m
yk := yearKey(taxpayerID, t.Year())
y := l.years[yk]
y.ClosedMonths++
l.years[yk] = y
}
return m, nil
}
func (l *Ledger) Year(taxpayerID string, year int) (YearSummary, error) {
if !safeID.MatchString(taxpayerID) || year < 2026 || year > 2100 {
return YearSummary{}, fail("invalid_request", "invalid taxpayer ID or year")
}
l.mu.Lock()
defer l.mu.Unlock()
y, ok := l.years[yearKey(taxpayerID, year)]
if !ok {
return YearSummary{}, fail("not_found", "year has no committed records")
}
return y, nil
}
进程内锁仅用于演示计算的原子性,重启后内存数据会丢失,多实例之间也不能互相锁定。生产部署应将这段临界区替换为数据库事务和唯一约束。
七、MySQL 事务:防止重复扣税与月结竞态
1. 先创建可锁定的行,再锁年度账户
不能只写 SELECT ... FOR UPDATE 就认为首次入账已经安全:账户尚不存在时,两个并发请求都可能走初始化分支。需要先依赖唯一键创建或命中同一行,再锁定读取。
生产事务可以按下面顺序组织;这是持久化适配层的实现步骤,配套内存服务没有执行这些 SQL:
- 在当前口径下查询业务号;若已存在,校验输入摘要并返回原结果。
- 创建或命中
tax_period月度门闩,取得共享锁,并检查该期间仍开放。 - 锁定长期存在的
taxpayer身份行,以SELECT ... FOR UPDATE当前读查询该人在本口径内跨年度的最新收入时间;随后初始化并锁定tax_account年度账户。 - 在锁内以当前读再次检查业务号,保证等待其他事务后也能发现已提交的重复请求。
- 验证时间顺序、所属年度及月结状态,计算本笔增量。
- 写
commission_ledger,更新账户和月汇总;需要异步事件时同时写tax_outbox。 - 提交事务后返回已保存的结果。
所有入口都按期间门闩 → 纳税人身份行 → 年度账户 → 月汇总的顺序加锁,减少交叉死锁;遇到数据库死锁,可以围绕完整事务做有界重试,重试继续使用原业务号。
身份行锁补上了年度账户锁的跨年边界:如果只锁年度行,同一个人 12 月和次年 1 月的并发请求不会互相等待,也无法可靠拒绝已经进入下一年后的倒序补录。本文保守地串行同一个人的不同平台;高吞吐场景可以增加“口径+自然人”的独立门闩,减少无关口径之间的争用。
MySQL 默认 REPEATABLE READ 下,事务首次普通查询可能已经建立了旧快照。即使后来取得身份行锁,继续普通查询也未必看见等待期间新提交的流水。因此锁内的最新流水查询和幂等复查必须使用当前读,不能只在账户查询上加锁。
不要使用可能悄悄吞掉其他错误的宽泛 INSERT IGNORE 来处理初始化。应只处理预期的主键重复,再锁行读取。不同自然人仍可并行处理。期间行适合在开放该月时提前初始化,避免每笔初始化产生不必要的排他锁。
2. 为什么还需要 tax_period
只锁已有的 tax_month 行,不能阻止另一笔交易为此前没有收入的人新增行。平台整月冻结时,需要一个“口径+月份”的门闩:
- 逐笔入账取得共享锁,允许不同自然人的入账并行。
- 整月结账取得排他锁,等待在途事务结束,核对并冻结期间。
- 冻结后即使是该月第一笔收入,也会被期间状态挡住。
示例 HTTP 的 /close 只冻结单个自然人的月度汇总,便于独立复现。平台整月结账、申报文件生成和申报回执管理是生产设计的扩展,不能因为一个人的 /close 成功就标记整个平台已关账。
3. 银行支付与数据库不是同一个事务
数据库提交后调用支付渠道,可能遇到超时但实际已付款;先付款再落账,也可能出现付款成功、账本失败。需要单独的支付指令、渠道幂等号、状态查询和对账任务。
事务内的 tax_outbox 负责可靠留下事件,消费端仍要幂等。不能在外部调用超时后直接删除原佣金、退回税款并换一个业务号再付。示例不连接支付渠道,net_cents 是账本计算结果,不是银行到账证明。
八、月度汇总:核对、冻结、申报、缴库
1. 月度汇总从实际流水求和
月报核心字段是当月收入、实际预扣税款、实收金额与笔数。即使新月份导致累计理论税额下降,也应汇总流水中的实际预扣值,不把理论税额当作现金扣款额。
月结至少核对三组关系:
月收入 = 本月所有有效确认流水的 gross_cents 之和
月实扣 = 本月所有有效确认流水的 withheld_cents 之和
月收入 = 月实扣 + 月实收
若当月有渠道支付,还需要按支付状态对账。支付处理中、失败待核查的业务不能直接等同于已到账,也不能仅凭支付失败就认定已确认收入必然不存在。
2. 不要用一个 status=SUCCESS 覆盖所有状态
建议将月度申报过程表达为:
月汇总 OPEN → 核对通过 → CLOSED
申报版本 DRAFT → EXPORTED → SUBMITTED → ACCEPTED
缴款状态 UNPAID → PROCESSING → PAID(或失败后核查)
本地冻结、导出文件、税务接收成功和税款缴库成功分别留存结果。更正申报使用新的 revision,引用原申报回执;同一份冻结快照应具有稳定内容摘要,便于定位实际申报的是哪一版。
个税扣缴申报、缴库一般在次月 15 日内完成,具体期限按当期办税日历处理,依据见2018 年第 61 号公告第二、三条。天津税务的平台劳务申报操作说明也将申报报送与税款缴纳分开,劳务项目选择“其他连续劳务报酬”。内部字段需要映射到实际申报表,不能把本文自定义 HTTP 接口当成税务机关接口。
九、年度清算:平台年度汇总与个人年度汇算
1. 平台全年汇总不重置历史已扣税
平台年度接口按纳税年度汇总该人全部连续段,输出全年劳务收入、已预扣税额、实收、笔数与已冻结月份数;需要月明细时调用月度接口。6、7 月与 10—12 月即使分成两段,全年仍应合计为 35,700 元收入、106.80 元预扣。
年度快照应说明“本平台口径”和数据截止时间。尚未结束的年度只代表截至当前的汇总;12 月关账也不能代替个人办理下一年的年度汇算。
2. 年度汇算合并个人全部综合所得
个人年度汇算一般在次年 3 月 1 日至 6 月 30 日办理,计算需要全年综合所得及适用扣除、减免税额、已预缴税额等数据。依法存在无需办理的情形。参见《个人所得税综合所得汇算清缴管理办法》第三至七条。
在本文年度估算的简化范围内:
全年综合所得收入额
= 工资薪金收入
+ 劳务报酬收入 × 80%
+ 特许权使用费收入 × 80%
+ 稿酬收入 × 80% × 70%
全年应纳税所得额
= max(全年综合所得收入额 - 60,000 - 符合条件的年度扣除, 0)
全年应纳税额
= max(全年应纳税所得额 × 年度税率 - 速算扣除数 - 减免税额, 0)
估算退补差额 = 全年应纳税额 - 全年已预缴税额
收入额的换算依据见《个人所得税法》第六条。年度综合所得税率对应前文七档;这里的应纳税所得额按全年口径计算。
6 万元基本减除费用按本例的居民个人年度规则扣一次,不能每个平台或每个连续段各扣一次。deductions_cents 输入的是经过合法性、额度和重复扣除核验后的年度扣除总额,不含这 6 万元,也不含已在收入额换算中减去的 20% 费用。真实业务应按扣除类别和凭证保存明细;公益捐赠限额等不能由客户端随意填一个总数绕过。
示例只做数学估算,不覆盖境外所得抵免、特殊居民身份、单独计税项目和全部优惠情形,也不自动判断免办汇算条件。
3. 年度估算接口如何使用
假设个人确认:全年工资收入 120,000 元、劳务收入 96,000 元,另有合法年度扣除 24,000 元,全年实际已预缴 10,000 元,无其他项目。输入:
{
"wages_cents": 12000000,
"labor_cents": 9600000,
"royalties_cents": 0,
"manuscripts_cents": 0,
"deductions_cents": 2400000,
"relief_cents": 0,
"prepaid_cents": 1000000
}
算式为:
收入额 = 120,000 + 96,000 × 80% = 196,800 元
应纳税所得额 = 196,800 - 60,000 - 24,000 = 112,800 元
应纳税额 = 112,800 × 10% - 2,520 = 8,760 元
退补差额 = 8,760 - 10,000 = -1,240 元
负数表示在这些假设下有 1,240 元的潜在退税额;实际退税需通过法定申报、审核和退库流程。正数表示估算应补差额,但是否需要办理还要结合完整政策条件判断。
如果前面的 35,700 元就是个人全年全部综合所得,且符合本例年度条件,那么收入额 28,560 元低于 6 万元,年度应纳税额为零,106.80 元预扣可能通过年度汇算申请退回。只要还存在其他工资或平台收入,这个结论就需要重算。
创建 annual.go,与实时计税共用 taxOnBase:
package commissiontax
import "fmt"
// AnnualInput must cover the person's complete comprehensive income, not just
// one platform. Deductions exclude the once-per-year basic RMB 60,000 deduction.
type AnnualInput struct {
WagesCents int64 `json:"wages_cents"`
LaborCents int64 `json:"labor_cents"`
RoyaltiesCents int64 `json:"royalties_cents"`
ManuscriptsCents int64 `json:"manuscripts_cents"`
DeductionsCents int64 `json:"deductions_cents"`
ReliefCents int64 `json:"relief_cents"`
PrepaidCents int64 `json:"prepaid_cents"`
}
type AnnualEstimate struct {
IncomeAmount100 int64 `json:"income_amount_hundredth_cent"`
TaxableBase100 int64 `json:"taxable_base_hundredth_cent"`
TaxCents int64 `json:"tax_cents"`
PrepaidCents int64 `json:"prepaid_cents"`
BalanceCents int64 `json:"balance_cents"` // positive supplement, negative refund
Status string `json:"status"`
}
func EstimateAnnual(in AnnualInput) (AnnualEstimate, error) {
values := []int64{in.WagesCents, in.LaborCents, in.RoyaltiesCents,
in.ManuscriptsCents, in.DeductionsCents, in.ReliefCents, in.PrepaidCents}
for _, v := range values {
if !validMoney(v) {
return AnnualEstimate{}, fmt.Errorf("annual amounts must be between 0 and %d cents", MaxMoney)
}
}
if in.WagesCents+in.LaborCents+in.RoyaltiesCents+in.ManuscriptsCents > MaxMoney {
return AnnualEstimate{}, fmt.Errorf("annual gross aggregate exceeds limit")
}
income100 := in.WagesCents*100 + in.LaborCents*80 + in.RoyaltiesCents*80 + in.ManuscriptsCents*56
base100 := income100 - (6_000_000+in.DeductionsCents)*100
if base100 < 0 {
base100 = 0
}
tax := taxOnBase(base100) - in.ReliefCents
if tax < 0 {
tax = 0
}
return AnnualEstimate{income100, base100, tax, in.PrepaidCents, tax - in.PrepaidCents, "estimate_only"}, nil
}
平台如果代为办理个人年度汇算,还需要本人按规定确认并提供完整资料;有关授权和办理方式见《汇算清缴管理办法》第十四至十六条。接口返回估算金额不表示已经取得授权或完成申报。
十、运行示例与验证接口
1. HTTP 校验、错误映射与路由
server.go 给出所有接口的完整处理,字段名严格匹配 JSON tag,拒绝重复字段、大小写别名、null、未知字段和尾随 JSON。金额的小数、超出 int64 的数值也会被拒绝:
package commissiontax
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"reflect"
"strconv"
"strings"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, err error) {
var e *Error
if !errors.As(err, &e) {
e = &Error{"invalid_request", err.Error()}
}
status := http.StatusBadRequest
switch e.Code {
case "not_found":
status = http.StatusNotFound
case "idempotency_conflict", "period_closed", "out_of_order":
status = http.StatusConflict
case "amount_limit", "insufficient_gross":
status = http.StatusUnprocessableEntity
case "ledger_inconsistency":
status = http.StatusInternalServerError
}
writeJSON(w, status, e)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, 8192)
decoder := json.NewDecoder(r.Body)
var raw json.RawMessage
if err := decoder.Decode(&raw); err != nil {
return fail("invalid_request", "invalid JSON: "+err.Error())
}
if err := decoder.Decode(new(any)); err != io.EOF {
return fail("invalid_request", "request must contain exactly one JSON value")
}
// All endpoints accept flat tagged structs. Match JSON tags exactly before
// decoding: encoding/json otherwise accepts case-insensitive field aliases,
// which could let GROSS_CENTS silently override an earlier gross_cents.
allowed := map[string]bool{}
inputType := reflect.TypeOf(dst).Elem()
for i := 0; i < inputType.NumField(); i++ {
field := inputType.Field(i)
name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
if field.PkgPath == "" && name != "" && name != "-" {
allowed[name] = true
}
}
// Reject null, duplicate fields and explicit null field values instead of
// silently turning them into zeros.
fields := json.NewDecoder(bytes.NewReader(raw))
opening, err := fields.Token()
if err != nil || opening != json.Delim('{') {
return fail("invalid_request", "request must be a JSON object")
}
seen := map[string]bool{}
for fields.More() {
key, _ := fields.Token()
name := key.(string)
if !allowed[name] {
return fail("invalid_request", "unknown field (exact JSON tag required): "+name)
}
var value json.RawMessage
if err := fields.Decode(&value); err != nil {
return fail("invalid_request", "invalid field value")
}
if seen[name] || bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return fail("invalid_request", "duplicate or null field: "+name)
}
seen[name] = true
}
checked := json.NewDecoder(bytes.NewReader(raw))
checked.DisallowUnknownFields()
if err := checked.Decode(dst); err != nil {
return fail("invalid_request", "invalid JSON: "+err.Error())
}
return nil
}
// NewHandler exposes a LOCAL demo, without identity authentication or object
// authorization. Production must derive scope and object permissions from
// verified server-side identity before invoking the ledger.
func NewHandler(l *Ledger) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/commissions", func(w http.ResponseWriter, r *http.Request) {
var req CommissionRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, err)
return
}
result, err := l.Post(req)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
})
mux.HandleFunc("GET /v1/taxpayers/{id}/months/{month}", func(w http.ResponseWriter, r *http.Request) {
result, err := l.Month(r.PathValue("id"), r.PathValue("month"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
})
mux.HandleFunc("POST /v1/taxpayers/{id}/months/{month}/close", func(w http.ResponseWriter, r *http.Request) {
var body struct{}
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, err)
return
}
result, err := l.CloseMonth(r.PathValue("id"), r.PathValue("month"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
})
mux.HandleFunc("GET /v1/taxpayers/{id}/years/{year}", func(w http.ResponseWriter, r *http.Request) {
yearText := r.PathValue("year")
year, err := strconv.Atoi(yearText)
if err != nil || len(yearText) != 4 {
writeError(w, fail("invalid_request", "year must contain four digits"))
return
}
result, err := l.Year(r.PathValue("id"), year)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
})
mux.HandleFunc("POST /v1/annual-estimates", func(w http.ResponseWriter, r *http.Request) {
var in AnnualInput
if err := decodeJSON(w, r, &in); err != nil {
writeError(w, err)
return
}
result, err := EstimateAnnual(in)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
})
return mux
}
2. 服务入口与运行命令
创建 cmd/server/main.go,固定使用服务端计税口径,并配置基本 HTTP 超时:
package main
import (
"log"
"net/http"
"time"
commissiontax "commissiontax.local"
)
func main() {
server := &http.Server{
Addr: "127.0.0.1:8080",
Handler: commissiontax.NewHandler(commissiontax.NewLedger("demo-platform")),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("LOCAL in-memory demo at http://%s (restart loses data)", server.Addr)
log.Fatal(server.ListenAndServe())
}
把本文代码块分别保存到对应文件后,在包含 go.mod 的目录中运行:
go test -race ./...
go vet ./...
go run ./cmd/server
测试文件在本节末尾完整列出。正式接口还需要认证、对象权限和访问审计。
3. 实时佣金、月结与年度接口调用
服务监听 127.0.0.1:8080,仅供本机实验,没有认证功能,重启会清空数据。下面在另一终端连续确认两笔收入:
curl -sS http://127.0.0.1:8080/v1/commissions \
-H 'Content-Type: application/json' \
-d '{"biz_id":"commission-202606-0001","taxpayer_id":"person-1001","commission_at":"2026-06-03T09:30:00+08:00","paid_at":"2026-06-03T10:00:00+08:00","gross_cents":300000}'
curl -sS http://127.0.0.1:8080/v1/commissions \
-H 'Content-Type: application/json' \
-d '{"biz_id":"commission-202606-0002","taxpayer_id":"person-1001","commission_at":"2026-06-03T09:30:00+08:00","paid_at":"2026-06-03T11:00:00+08:00","gross_cents":400000}'
第一笔 withheld_cents=0,第二笔 withheld_cents=1800、net_cents=398200。第二笔响应中的关键字段如下,省略了输入回显和其他字段:
{
"scope": "demo-platform",
"tax_month": "2026-06",
"continuous_months": 1,
"segment_gross_cents": 700000,
"target_tax_cents": 1800,
"withheld_cents": 1800,
"segment_paid_cents": 1800,
"net_cents": 398200,
"rule_version": "platform-2025-16-basic-v1"
}
继续查询、冻结和估算:
curl -sS http://127.0.0.1:8080/v1/taxpayers/person-1001/months/2026-06
curl -sS http://127.0.0.1:8080/v1/taxpayers/person-1001/months/2026-06/close \
-H 'Content-Type: application/json' -d '{}'
curl -sS http://127.0.0.1:8080/v1/taxpayers/person-1001/years/2026
curl -sS http://127.0.0.1:8080/v1/annual-estimates \
-H 'Content-Type: application/json' \
-d '{"wages_cents":12000000,"labor_cents":9600000,"royalties_cents":0,"manuscripts_cents":0,"deductions_cents":2400000,"relief_cents":0,"prepaid_cents":1000000}'
第二笔响应还会包含 calculation 参数快照,当前场景为 income_cents=700000、expense_cents_x100=14000000、basic_deduction_cents=500000,其余扣除与减免为零,already_withheld_cents=0。taxable_cents_x100=6000000 表示 600 元应纳税所得额,扣税后的段实扣为 segment_paid_cents=1800。booked_at 是本次首次入账的系统时间,幂等重试返回原时间。
月度汇总应为 count=2、gross_cents=700000、withheld_cents=1800、net_cents=698200,冻结后 closed=true。再次提交相同两笔请求仍返回原结果;新增不同业务号的 6 月收入被拒绝。年度估算返回 tax_cents=876000、balance_cents=-124000、status=estimate_only。
测试覆盖政策例子中的 18、30、22.80、0、36 元序列,同月拆分、月首目标下降、跨年与旧年度汇总保留、税率边界及分位舍入,以及并发重试、金额上限、倒序拒绝、月结后幂等和汇总损坏时拒绝冻结。它们验证示例的计算与账本行为,不代替真实数据库或税务渠道的集成验证。
4. 完整测试代码
将下面三个代码块保存成对应测试文件,即可运行前面的 go test -race ./...。测试只使用本机内存与 httptest,不访问支付渠道或税务机关。完整参数测试核对的是通用公式算术,平台劳务 HTTP 仍只采用本场景允许的服务端参数。
tax_test.go:税率边界、分位舍入与年度综合所得
package commissiontax
import (
"reflect"
"testing"
)
func TestCompleteCumulativeFormula(t *testing.T) {
// All amounts are cumulative for the same scope/segment. This exercises
// every formula parameter; it does not assert any deduction's eligibility.
p := CumulativeParams{
IncomeCents: 1_000_000,
ExpenseCents100: 20_000_000,
ExemptCents: 10_000,
BasicDeductionCents: 500_000,
SpecialDeductionCents: 20_000,
AdditionalDeductionCents: 30_000,
OtherDeductionCents: 40_000,
ReliefCents: 1000,
AlreadyWithheldCents: 2000,
}
// Taxable RMB 2,000; tax 60 - relief 10 - already withheld 20 = 30.
got, err := CalculateCumulative(p)
if err != nil || got != (CumulativeResult{20_000_000, 5000, 3000}) {
t.Fatalf("complete formula=%+v, %v", got, err)
}
p.AlreadyWithheldCents = 6000
got, err = CalculateCumulative(p)
if err != nil || got.TargetTaxCents != 5000 || got.WithheldCents != 0 {
t.Fatalf("negative difference should withhold zero: %+v, %v", got, err)
}
p.ReliefCents = 10_000
got, err = CalculateCumulative(p)
if err != nil || got.TargetTaxCents != 0 || got.WithheldCents != 0 {
t.Fatalf("negative target should clamp zero: %+v, %v", got, err)
}
p.ExpenseCents100 = p.IncomeCents * 100
got, err = CalculateCumulative(p)
if err != nil || got.TaxableCents100 != 0 {
t.Fatalf("negative base should clamp zero: %+v, %v", got, err)
}
}
func TestCumulativeParameterBounds(t *testing.T) {
var p CumulativeParams
typ := reflect.TypeOf(p)
for i := 0; i < typ.NumField(); i++ {
limit := MaxMoney
if typ.Field(i).Name == "ExpenseCents100" {
limit = MaxMoney * 100
}
for _, bad := range []int64{-1, limit + 1} {
input := CumulativeParams{}
reflect.ValueOf(&input).Elem().Field(i).SetInt(bad)
if _, err := CalculateCumulative(input); err == nil {
t.Errorf("accepted %s=%d", typ.Field(i).Name, bad)
}
}
}
if _, err := CalculateCumulative(CumulativeParams{IncomeCents: MaxMoney}); err != nil {
t.Fatalf("maximum accepted income: %v", err)
}
if _, err := CalculateCumulative(CumulativeParams{IncomeCents: MaxMoney, ExpenseCents100: MaxMoney * 100}); err != nil {
t.Fatalf("maximum expense equal to income: %v", err)
}
if _, err := CalculateCumulative(CumulativeParams{IncomeCents: 100, ExpenseCents100: 10_001}); err == nil {
t.Fatal("accepted expense larger than income")
}
}
func TestCumulativeBracketBoundaries(t *testing.T) {
// Direct exact and sub-cent taxable bases exercise every boundary, not
// just gross-income values reachable after an 80% fee deduction.
for _, tc := range []struct{ base, want int64 }{
{0, 0}, {3_600_000 * 100, 108_000},
{3_600_000*100 + 100, 108_000},
{14_400_000 * 100, 1_188_000},
{30_000_000 * 100, 4_308_000},
{42_000_000 * 100, 7_308_000},
{66_000_000 * 100, 14_508_000},
{96_000_000 * 100, 25_008_000},
{96_000_000*100 + 100, 25_008_000},
} {
if got := taxOnBase(tc.base); got != tc.want {
t.Errorf("taxOnBase(%d)=%d, want %d", tc.base, got, tc.want)
}
}
for _, tc := range []struct {
gross int64
months int
want int64
}{
{700_000, 1, 1800}, {1_450_000, 2, 4800}, {625_000, 1, 0},
{625_020, 1, 0}, {625_021, 1, 1}, // 0.48 vs 0.504 cent, round once
} {
got, err := CumulativeTax(tc.gross, tc.months)
if err != nil || got != tc.want {
t.Errorf("CumulativeTax(%d,%d)=(%d,%v), want %d", tc.gross, tc.months, got, err, tc.want)
}
}
for _, tc := range []struct {
gross int64
months int
}{{-1, 1}, {MaxMoney + 1, 1}, {100, 0}, {100, 13}} {
if _, err := CumulativeTax(tc.gross, tc.months); err == nil {
t.Errorf("accepted invalid %+v", tc)
}
}
}
func TestAnnualComprehensiveEstimate(t *testing.T) {
// Income amount = 120000 + 100000*.8 + 20000*.8 + 10000*.56
// = 221600; taxable = 221600 - 60000 - 20000 = 141600.
in := AnnualInput{WagesCents: 12_000_000, LaborCents: 10_000_000, RoyaltiesCents: 2_000_000, ManuscriptsCents: 1_000_000, DeductionsCents: 2_000_000, ReliefCents: 10_000, PrepaidCents: 1_200_000}
got, err := EstimateAnnual(in)
if err != nil {
t.Fatal(err)
}
if got.IncomeAmount100 != 2_216_000_000 || got.TaxableBase100 != 1_416_000_000 || got.TaxCents != 1_154_000 || got.BalanceCents != -46_000 || got.Status != "estimate_only" {
t.Fatalf("unexpected estimate: %+v", got)
}
in = AnnualInput{LaborCents: 10_000_000, PrepaidCents: 600_000}
got, err = EstimateAnnual(in)
if err != nil || got.TaxCents != 60_000 || got.BalanceCents != -540_000 {
t.Fatalf("labor-only annual: %+v, %v", got, err)
}
if _, err := EstimateAnnual(AnnualInput{LaborCents: MaxMoney, WagesCents: 1}); err == nil {
t.Fatal("accepted annual aggregate overflow")
}
if _, err := EstimateAnnual(AnnualInput{PrepaidCents: -1}); err == nil {
t.Fatal("accepted negative prepaid")
}
}
ledger_test.go:断月、跨年、幂等、并发与月结核对
package commissiontax
import (
"errors"
"fmt"
"reflect"
"sync"
"testing"
"time"
)
func request(id, taxpayer, paidAt string, gross int64) CommissionRequest {
t, err := time.Parse(time.RFC3339, paidAt)
if err != nil {
panic(err)
}
return CommissionRequest{BizID: id, TaxpayerID: taxpayer, CommissionAt: t, PaidAt: t, GrossCents: gross}
}
func requireCode(t *testing.T, err error, code string) {
t.Helper()
var e *Error
if !errors.As(err, &e) || e.Code != code {
t.Fatalf("error=%v, want code %q", err, code)
}
}
func TestOfficialSequenceAndYearSummary(t *testing.T) {
l := NewLedger("demo-platform")
for _, tc := range []struct {
month string
gross, tax int64
start string
months int
}{
{"06", 700_000, 1800, "2026-06", 1}, {"07", 750_000, 3000, "2026-06", 2},
{"10", 720_000, 2280, "2026-10", 1}, {"11", 600_000, 0, "2026-10", 2},
{"12", 800_000, 3600, "2026-10", 3},
} {
got, err := l.Post(request("biz-"+tc.month, "person-1", "2026-"+tc.month+"-10T12:00:00+08:00", tc.gross))
if err != nil {
t.Fatal(err)
}
if got.WithheldCents != tc.tax || got.SegmentStart != tc.start || got.ContinuousMonths != tc.months || got.GrossCents != got.NetCents+got.WithheldCents {
t.Fatalf("unexpected: %+v", got)
}
month, err := l.Month("person-1", "2026-"+tc.month)
if err != nil || month.Count != 1 || month.WithheldCents != tc.tax {
t.Fatalf("month=%+v err=%v", month, err)
}
}
year, err := l.Year("person-1", 2026)
if err != nil || year.GrossCents != 3_570_000 || year.WithheldCents != 10_680 || year.Count != 5 {
t.Fatalf("year=%+v err=%v", year, err)
}
// New year starts a new segment. Keep 2026 annual totals intact.
jan, err := l.Post(request("jan", "person-1", "2027-01-02T12:00:00+08:00", 700_000))
if err != nil || jan.WithheldCents != 1800 || jan.ContinuousMonths != 1 || jan.SegmentPaidCents != 1800 {
t.Fatalf("new year: %+v, %v", jan, err)
}
after, _ := l.Year("person-1", 2026)
if !reflect.DeepEqual(after, year) {
t.Fatal("prior annual summary changed")
}
}
func TestSameMonthSplitAndMonthStartNoRefund(t *testing.T) {
l := NewLedger("demo-platform")
first, err := l.Post(request("a", "p", "2026-06-01T10:00:00+08:00", 500_000))
if err != nil || first.WithheldCents != 0 {
t.Fatalf("first=%+v, %v", first, err)
}
second, err := l.Post(request("b", "p", "2026-06-01T11:00:00+08:00", 200_000))
if err != nil || second.WithheldCents != 1800 || second.ContinuousMonths != 1 {
t.Fatalf("second=%+v, %v", second, err)
}
if second.Calculation.IncomeCents != 700_000 || second.Calculation.ExpenseCents100 != 14_000_000 || second.Calculation.BasicDeductionCents != 500_000 || second.Calculation.AlreadyWithheldCents != 0 || second.TaxableCents100 != 6_000_000 {
t.Fatalf("second event parameter snapshot: %+v", second)
}
start, err := l.Post(request("c", "p", "2026-07-01T11:00:00+08:00", 1))
if err != nil || start.WithheldCents != 0 || start.TargetTaxCents != 0 || start.SegmentPaidCents != 1800 {
t.Fatalf("month start=%+v, %v", start, err)
}
if start.Calculation.AlreadyWithheldCents != 1800 {
t.Fatalf("snapshot must retain pre-event already-withheld amount: %+v", start.Calculation)
}
end, err := l.Post(request("d", "p", "2026-07-02T11:00:00+08:00", 749_999))
if err != nil || end.WithheldCents != 3000 || end.SegmentPaidCents != 4800 || end.ContinuousMonths != 2 {
t.Fatalf("end=%+v, %v", end, err)
}
month, _ := l.Month("p", "2026-07")
if month.WithheldCents != 3000 || month.Count != 2 {
t.Fatalf("month=%+v", month)
}
}
func TestCommissionAndIncomeTimes(t *testing.T) {
l := NewLedger("demo-platform")
req := request("cross-year", "p", "2026-01-02T12:00:00+08:00", 700_000)
req.CommissionAt = time.Date(2025, 12, 31, 12, 0, 0, 0, shanghai)
before := time.Now().UTC()
got, err := l.Post(req)
after := time.Now().UTC()
if err != nil {
t.Fatal(err)
}
if got.TaxMonth != "2026-01" || got.CommissionAt.Year() != 2025 || got.PaidAt.Year() != 2026 {
t.Fatalf("commission year must not determine income month: %+v", got)
}
if got.BookedAt.Before(before) || got.BookedAt.After(after) || got.BookedAt.Location() != time.UTC {
t.Fatalf("booked_at must record the current UTC system time: %s", got.BookedAt)
}
changed := req
changed.CommissionAt = changed.CommissionAt.Add(-time.Hour)
_, err = l.Post(changed)
requireCode(t, err, "idempotency_conflict")
if _, err = l.Year("p", 2025); err == nil {
t.Fatal("business generation time created a prior-year income summary")
}
for _, tc := range []struct {
name string
commission, paid time.Time
}{
{"missing-commission", time.Time{}, req.PaidAt},
{"missing-paid", req.CommissionAt, time.Time{}},
{"late-commission", req.PaidAt.Add(time.Second), req.PaidAt},
} {
input := req
input.BizID = tc.name
input.CommissionAt, input.PaidAt = tc.commission, tc.paid
_, err := l.Post(input)
requireCode(t, err, "invalid_request")
}
y, err := l.Year("p", 2026)
if err != nil || y.Count != 1 || y.GrossCents != req.GrossCents {
t.Fatalf("invalid time requests mutated income totals: %+v, %v", y, err)
}
}
func TestIdempotencyCloseAndOrdering(t *testing.T) {
l := NewLedger("demo-platform")
req := request("idempotent", "p", "2026-06-01T10:00:00+08:00", 700_000)
original, err := l.Post(req)
if err != nil {
t.Fatal(err)
}
retry, err := l.Post(req)
if err != nil || !reflect.DeepEqual(original, retry) {
t.Fatalf("retry=%+v err=%v", retry, err)
}
for _, changed := range []CommissionRequest{
request(req.BizID, "other", "2026-06-01T10:00:00+08:00", 700_000),
request(req.BizID, "p", "2026-06-01T10:00:00+08:00", 700_001),
request(req.BizID, "p", "2026-06-01T11:00:00+08:00", 700_000),
} {
_, err := l.Post(changed)
requireCode(t, err, "idempotency_conflict")
}
_, err = l.Post(request("past", "p", "2026-05-31T10:00:00+08:00", 100))
requireCode(t, err, "out_of_order")
closed, err := l.CloseMonth("p", "2026-06")
if err != nil || !closed.Closed {
t.Fatalf("close=%+v err=%v", closed, err)
}
_, _ = l.CloseMonth("p", "2026-06")
y, _ := l.Year("p", 2026)
if y.ClosedMonths != 1 {
t.Fatalf("close retry counted twice: %+v", y)
}
_, err = l.Post(request("new", "p", "2026-06-02T10:00:00+08:00", 100))
requireCode(t, err, "period_closed")
retry, err = l.Post(req)
if err != nil || !reflect.DeepEqual(original, retry) {
t.Fatalf("closed retry failed: %+v err=%v", retry, err)
}
}
func TestConcurrentSameTimeAndDuplicate(t *testing.T) {
l := NewLedger("demo-platform")
var wg sync.WaitGroup
errs := make(chan error, 100)
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
req := request(fmt.Sprintf("biz-%d", i%50), "p", "2026-06-01T10:00:00+08:00", 20_000)
_, err := l.Post(req)
errs <- err
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
m, err := l.Month("p", "2026-06")
if err != nil || m.Count != 50 || m.GrossCents != 1_000_000 || m.WithheldCents != 9000 || m.NetCents != 991_000 {
t.Fatalf("concurrent summary=%+v err=%v", m, err)
}
}
func TestCloseRejectsCorruptedSummary(t *testing.T) {
l := NewLedger("demo-platform")
_, err := l.Post(request("c", "p", "2026-06-01T10:00:00+08:00", 700_000))
if err != nil {
t.Fatal(err)
}
k := monthKey("p", "2026-06")
m := l.months[k]
m.WithheldCents++
l.months[k] = m
_, err = l.CloseMonth("p", "2026-06")
requireCode(t, err, "ledger_inconsistency")
if l.months[k].Closed {
t.Fatal("corrupt month was frozen")
}
y, _ := l.Year("p", 2026)
if y.ClosedMonths != 0 {
t.Fatal("failed close mutated annual summary")
}
}
func TestShanghaiMonthAndAggregateLimit(t *testing.T) {
l := NewLedger("demo-platform")
got, err := l.Post(request("timezone", "p", "2026-06-30T16:00:00Z", 700_000))
if err != nil || got.TaxMonth != "2026-07" {
t.Fatalf("timezone=%+v err=%v", got, err)
}
_, err = l.Post(request("older", "p", "2026-06-30T15:59:59Z", 700_000))
requireCode(t, err, "out_of_order")
_, err = l.Post(request("large", "q", "2026-06-01T10:00:00+08:00", MaxMoney))
if err != nil {
t.Fatal(err)
}
_, err = l.Post(request("excess", "q", "2026-08-01T10:00:00+08:00", 1))
requireCode(t, err, "amount_limit")
y, _ := l.Year("q", 2026)
if y.Count != 1 || y.GrossCents != MaxMoney {
t.Fatalf("rejected event mutated year: %+v", y)
}
}
server_test.go:HTTP 流程与请求校验
package commissiontax
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func call(h http.Handler, method, path, body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
h.ServeHTTP(w, r)
return w
}
func TestHTTPFlow(t *testing.T) {
h := NewHandler(NewLedger("demo-platform"))
body := `{"biz_id":"c-1","taxpayer_id":"p","commission_at":"2026-05-31T12:00:00+08:00","paid_at":"2026-06-01T12:00:00+08:00","gross_cents":700000}`
w := call(h, "POST", "/v1/commissions", body)
if w.Code != 200 {
t.Fatalf("post status=%d %s", w.Code, w.Body)
}
var c Commission
if err := json.Unmarshal(w.Body.Bytes(), &c); err != nil || c.WithheldCents != 1800 {
t.Fatalf("entry=%+v, %v", c, err)
}
for _, path := range []string{"/v1/taxpayers/p/months/2026-06", "/v1/taxpayers/p/years/2026"} {
if w := call(h, "GET", path, ""); w.Code != 200 {
t.Fatalf("GET %s=%d %s", path, w.Code, w.Body)
}
}
if w := call(h, "POST", "/v1/taxpayers/p/months/2026-06/close", `{}`); w.Code != 200 {
t.Fatalf("close=%d %s", w.Code, w.Body)
}
if w := call(h, "POST", "/v1/commissions", body); w.Code != 200 {
t.Fatalf("retry after close=%d %s", w.Code, w.Body)
}
if w := call(h, "POST", "/v1/commissions", strings.Replace(body, `"c-1"`, `"c-2"`, 1)); w.Code != 409 {
t.Fatalf("closed mutation=%d %s", w.Code, w.Body)
}
w = call(h, "POST", "/v1/annual-estimates", `{"labor_cents":10000000,"prepaid_cents":600000}`)
if w.Code != 200 || !strings.Contains(w.Body.String(), `"balance_cents":-540000`) {
t.Fatalf("annual=%d %s", w.Code, w.Body)
}
}
func TestHTTPRejectsInvalidJSON(t *testing.T) {
h := NewHandler(NewLedger("demo-platform"))
valid := `{"biz_id":"c","taxpayer_id":"p","commission_at":"2026-05-31T12:00:00+08:00","paid_at":"2026-06-01T12:00:00+08:00","gross_cents":700000}`
for _, body := range []string{
valid + ` {}`, strings.Replace(valid, `700000`, `1.25`, 1),
strings.Replace(valid, `700000`, `9223372036854775808`, 1),
strings.Replace(valid, `700000`, `-1`, 1), strings.Replace(valid, `700000`, `null`, 1),
strings.Replace(valid, `"biz_id"`, `"scope":"untrusted","biz_id"`, 1),
strings.Replace(valid, `"biz_id":"c"`, `"biz_id":"first","biz_id":"c"`, 1),
strings.Replace(valid, `"gross_cents":700000`, `"gross_cents":700000,"GROSS_CENTS":1`, 1),
strings.Replace(valid, `"gross_cents"`, `"GROSS_CENTS"`, 1),
strings.Replace(valid, `"paid_at"`, `"Paid_At"`, 1),
strings.Replace(valid, `"commission_at":"2026-05-31T12:00:00+08:00",`, ``, 1),
strings.Replace(valid, `"commission_at":"2026-05-31T12:00:00+08:00"`, `"commission_at":"2026-06-02T12:00:00+08:00"`, 1),
strings.Replace(valid, `"commission_at"`, `"COMMISSION_AT"`, 1),
strings.Repeat(" ", 9000) + valid, `null`, `[]`, `{}`,
} {
if w := call(h, "POST", "/v1/commissions", body); w.Code != 400 {
t.Errorf("accepted malformed request: status=%d body=%q response=%s", w.Code, body[:min(len(body), 200)], w.Body)
}
}
for _, path := range []string{"/v1/annual-estimates", "/v1/taxpayers/p/months/2026-06/close"} {
if w := call(h, "POST", path, `null`); w.Code != 400 {
t.Errorf("accepted null on %s: %d %s", path, w.Code, w.Body)
}
}
if w := call(h, "POST", "/v1/annual-estimates", `{"labor_cents":null}`); w.Code != 400 {
t.Fatalf("accepted explicit null amount: %d %s", w.Code, w.Body)
}
for _, body := range []string{`{"WAGES_CENTS":12000000}`, `{"labor_cents":10000000,"LABOR_CENTS":0}`} {
if w := call(h, "POST", "/v1/annual-estimates", body); w.Code != 400 {
t.Errorf("accepted case-insensitive alias on annual endpoint: %d %s", w.Code, w.Body)
}
}
if w := call(h, "GET", "/v1/taxpayers/p/months/2026-1", ""); w.Code != 400 {
t.Fatalf("bad month: %d", w.Code)
}
if w := call(h, "GET", "/v1/taxpayers/p/months/2026-01", ""); w.Code != 404 {
t.Fatalf("empty month: %d", w.Code)
}
}
十一、历史补录、退款与更正如何处理
连续月份依赖历史收入事实。假设系统已经把 10 月作为新连续段入账,后来补进一笔 9 月收入,10 月及以后期间的连续状态就可能需要重算。不能只往当前累计收入加一笔金额。
生产实现应保留原流水、原计算版本和原申报快照,建立更正任务:
- 确认原收入事实、所属期间和更正原因。
- 从受影响期间之前的可靠状态开始,按收入时间及稳定序号重放。
- 生成新计算版本,列出各月差异及后续期间影响。
- 根据是否已申报、已缴库决定更正申报与补退税办理路径。
- 保留原记录与更正记录的关联,再更新生效视图。
退款也不能直接传 gross_cents=-10000 给本例实时接口。需要确认是原收入撤销、后续服务退款、资金支付失败还是税务口径更正;这几件事影响的时间和处理程序可能不同。
示例主动拒绝这些需要重放的输入,避免演示接口在没有更正流程时悄悄生成错误税款。
十二、上线前应补齐的实现
| 本文已提供 | 生产适配还需要完成 |
|---|---|
| 简化条件下的累计预扣与年度估算 | 所得分类、免税及其他法定事项的规则与证据 |
| 进程内幂等和串行记账 | MySQL 事务、唯一键、备份恢复和多实例并发验证 |
| 单人月度汇总与冻结 | 平台整月冻结、申报字段映射、回执与缴库对账 |
| 税款与实收计算结果 | 支付指令、渠道幂等、到账核查及资金对账 |
| 全口径年度估算输入 | 本人确认、资料核实与法定汇算办理 |
真正容易出错的地方通常是金额与状态的边界:同月重复增加月份、把理论税额当实扣、断月后混用上一段已扣税、跨年丢掉历史汇总,以及超时重试时换了业务号。把这些情况固化成测试,才能在扩展支付、申报和更正流程时继续核对同一套账。
