PAYATHON 2026

.NET 3.5 / C# 账单与发票系统设计最佳实践

支付老李

结论

账单与发票系统要解决的,不只是”每月生成一个 PDF”,还要保证每张发票都满足这些要求:

  • 金额可以复算,舍入规则固定;
  • 开票后,内容不会随业务数据的变化而改变;
  • 批量任务可以安全重跑,不会重复开票;
  • 发票编号唯一,连续性符合当地法规;
  • PDF、发送记录和状态变化都有审计记录;
  • 客户访问链接有期限、可以撤销,也不会暴露存储路径。

可以把系统分成”费用归集、发票定稿、PDF 渲染、文件发布、通知发送”几个独立阶段。不要让一个定时任务同时查询实时数据、计算金额、生成 PDF 和发送邮件。

先确认财务与合规规则

开发前,应由财务、税务或法务人员明确以下规则:

  • 发票在什么时间点视为正式开具;
  • 发票编号是否必须连续,能否出现空号;
  • 税额按行计算,还是按整张发票计算;
  • 折扣在税前还是税后计算;
  • 采用哪种舍入方式;
  • 如何处理贷项通知单、退款和作废;
  • 发票及相关记录需要保存多久;
  • 客户名称、地址、税号等字段有哪些法定要求;
  • PDF 是正式税务凭证,还是只用于展示账单。

这些规则会随国家、地区和业务类型变化,不能只凭软件开发经验决定。尤其不能把”保留两位小数”直接等同于税务计算正确。

使用快照保存已开票数据

发票不能一直引用客户、产品和订单的当前数据。即使客户改名、产品调价或地址发生变化,历史发票也要保留开具时的内容。

发票定稿时,应复制所有必要字段:

Invoice
├── InvoiceNumber
├── CustomerId
├── CustomerName
├── BillingAddress
├── CustomerTaxNumber
├── CurrencyCode
├── IssueDate
├── ServicePeriodStart
├── ServicePeriodEnd
├── Subtotal
├── TaxTotal
├── GrandTotal
└── Status

InvoiceLine
├── Description
├── Quantity
├── UnitPrice
├── DiscountAmount
├── TaxRate
├── NetAmount
├── TaxAmount
└── GrossAmount

CustomerId 用于关联客户,CustomerName、地址和税号则作为历史快照保存。发票行也要保存描述、单价和税率,不能只记录 ProductId。

正式发票通常不应直接修改。金额或税务信息有误时,一般需要作废后重新开具,或者生成贷项通知单。具体处理方式取决于当地法规。

金额必须使用 decimal

C# 中的货币计算应使用 decimal,不要使用 float 或 double:

decimal quantity = 3m;
decimal unitPrice = 19.95m;
decimal netAmount = quantity * unitPrice;

decimal taxRate = 0.13m;
decimal taxAmount = Math.Round(
    netAmount * taxRate,
    2,
    MidpointRounding.AwayFromZero);

decimal grossAmount = netAmount + taxAmount;

这里的 MidpointRounding.AwayFromZero 只是示例。实际项目必须采用财务确认的舍入规则,并明确以下事项:

  • 单价允许保留几位小数;
  • 中间计算保留几位;
  • 逐行舍入,还是汇总后统一舍入;
  • 折扣和税额按什么顺序计算;
  • 负数金额如何舍入;
  • 各币种分别保留几位小数。

数据库字段也应采用固定精度,例如 decimal(19, 4),不能使用浮点类型。页面显示两位小数,并不意味着数据库只能保存两位。计费单价可能需要更高的精度。

按比例分摊后不能整除的金额,还要明确余数如何处理。例如把 10.00 元平均分配到三项,可以分为 3.34、3.33、3.33,不能生成三个 3.33 后丢掉 0.01 元。

设计清晰的状态流转

系统至少可以区分以下状态:

Draft → Finalized → Rendered → Published → Sent

异常状态可以单独记录:

RenderFailed
PublishFailed
SendFailed
Voided

每个状态都要有固定含义:

  • Draft:允许重新计算和修改;
  • Finalized:金额、税额、客户快照和编号已经确定;
  • Rendered:PDF 已生成;
  • Published:客户已经可以访问文件;
  • Sent:通知已成功交给邮件服务;
  • Voided:发票已经失效,但相关记录仍然保留。

不要用一个简单的 IsSent 字段表示整个流程,否则无法判断失败发生在 PDF 生成、文件上传还是邮件发送阶段。

每次状态变化都应记录时间、执行者和失败原因。自动任务可以使用固定的系统执行者标识。

月度任务必须具备幂等性

定时任务可能因进程崩溃、服务器重启或人工补跑而重复执行。如果系统允许同一客户在同一账期生成两张正常发票,重复计费迟早会发生。

可以在数据库中建立业务唯一约束:

CREATE UNIQUE INDEX UX_Invoice_Customer_Period
ON Invoice(CustomerId, ServicePeriodStart, ServicePeriodEnd, InvoiceType);

创建发票时,应让数据库约束成为最后一道防线:

public Invoice GetOrCreateInvoice(
    int customerId,
    DateTime periodStart,
    DateTime periodEnd)
{
    Invoice existing = repository.Find(
        customerId,
        periodStart,
        periodEnd,
        InvoiceType.Standard);

    if (existing != null)
    {
        return existing;
    }

    try
    {
        return repository.Create(
            customerId,
            periodStart,
            periodEnd,
            InvoiceType.Standard);
    }
    catch (DuplicateInvoiceException)
    {
        // 另一个进程可能已经完成创建。
        return repository.Find(
            customerId,
            periodStart,
            periodEnd,
            InvoiceType.Standard);
    }
}

只做”先查询是否存在,再插入”仍然无法阻止并发重复,因此数据库唯一约束不可省略。

任务还要支持从中间阶段继续执行。如果发票已经定稿,但邮件发送失败,重跑时只需重新发送通知,不应重新计算金额或分配新编号。

发票编号应在定稿时分配

数据库主键和发票编号用途不同:

  • 主键用于系统内部关联;
  • 发票编号展示给客户,并且可能受到财务法规约束。

不要使用 SELECT MAX(InvoiceNumber) + 1。多个进程并发执行时,这种方式会产生重复编号。编号应通过数据库序列、专用计数表或带锁事务分配。

如果法规要求编号连续,还要考虑以下问题:

  • 事务回滚是否会产生空号;
  • 测试环境和生产环境是否共用编号范围;
  • 不同公司主体是否分别编号;
  • 是否需要跨年度重新开始;
  • 作废发票是否保留原编号。

编号格式是业务规则,不应散落在 PDF 模板或定时任务代码里。

将计算结果和 PDF 生成分离

执行顺序可以设计为:

  1. 确定账期边界。
  2. 归集该账期内尚未开票的费用。
  3. 计算发票行、折扣、税额和总额。
  4. 在事务中保存发票快照,并关联费用记录。
  5. 分配发票编号并定稿。
  6. 根据定稿后的快照生成 PDF。
  7. 保存文件并记录校验值。
  8. 创建受保护的访问链接。
  9. 发送通知并记录结果。

PDF 只是发票数据的一种呈现形式,不能作为唯一数据源。即使 PDF 文件丢失,系统也应能根据保存的发票快照重新生成内容完全相同的文件。

渲染 PDF 时只能读取已定稿的数据,不能再次查询客户当前地址、产品当前价格或实时税率。

防止重复归集费用

每笔可计费项目都应有稳定的唯一标识,并记录它最终归属的发票。例如:

BillableCharge
├── ChargeId
├── CustomerId
├── OccurredAt
├── Amount
├── CurrencyCode
├── InvoiceId
└── InvoicedAt

创建发票的数据库事务还应同时完成以下操作:

  • 插入发票及发票行;
  • 把费用记录关联到该发票;
  • 防止同一笔费用再次被其他正常发票引用。

不要只靠日期范围反复查询费用。迟到数据、时区边界和人工更正,都可能导致同一个查询在不同时间返回不同结果。

明确账期和时区

“每月”需要定义为确定的时间区间。例如:

[2026-08-01 00:00:00, 2026-09-01 00:00:00)

建议使用左闭右开的区间,避免在月末拼接 23:59:59.999。数据库最好存储 UTC 时间,同时保存或明确客户采用的计费时区。

以下情况需要专门测试:

  • 各月份天数不同;
  • 闰年二月;
  • 夏令时切换;
  • 客户跨时区;
  • 月初补录的上月费用;
  • 交易恰好发生在账期结束时刻。

PDF 文件和访问链接要分开管理

不要把可预测的文件名直接暴露成永久公开地址,例如:

/invoices/2026/000123.pdf

可以采用以下方式:

  • 将 PDF 存放在非公开目录或私有对象存储中;
  • 使用高强度随机令牌生成链接;
  • 只保存令牌的哈希值;
  • 为链接设置失效时间;
  • 允许撤销和重新签发;
  • 访问时验证客户身份或令牌;
  • 全程使用 HTTPS;
  • 记录访问时间和结果。

.NET 3.5 可以使用加密随机数生成令牌:

using System;
using System.Security.Cryptography;

public static class AccessTokenGenerator
{
    public static string CreateToken()
    {
        byte[] bytes = new byte[32];

        using (RandomNumberGenerator rng =
            RandomNumberGenerator.Create())
        {
            rng.GetBytes(bytes);
        }

        return ToHex(bytes);
    }

    private static string ToHex(byte[] bytes)
    {
        char[] chars = new char[bytes.Length * 2];
        const string hex = "0123456789abcdef";

        for (int i = 0; i < bytes.Length; i++)
        {
            chars[i * 2] = hex[bytes[i] >> 4];
            chars[i * 2 + 1] = hex[bytes[i] & 0x0f];
        }

        return new string(chars);
    }
}

不要用 Random、客户编号或发票编号生成访问令牌。URL 还可能被写入浏览器历史、代理日志和邮件安全扫描系统。对敏感程度较高的场景,要求客户登录后下载更合适。

记录 PDF 的完整性信息

PDF 生成后,可以保存以下信息:

  • 存储位置;
  • 文件大小;
  • MIME 类型;
  • 生成时间;
  • 模板版本;
  • 内容哈希值。

例如使用 SHA-256:

using System.IO;
using System.Security.Cryptography;

public static byte[] ComputeSha256(string filePath)
{
    using (FileStream stream = File.OpenRead(filePath))
    using (SHA256 sha256 = SHA256.Create())
    {
        return sha256.ComputeHash(stream);
    }
}

哈希值可以用来判断文件是否被替换或损坏,但哈希本身不是电子签名,也不能自动满足税务或档案方面的合规要求。

邮件发送与开票事务不要绑在一起

数据库事务不应一直等待 PDF 上传或 SMTP 响应。更稳妥的做法是使用”发件箱”记录:

InvoiceNotification
├── NotificationId
├── InvoiceId
├── Recipient
├── Status
├── AttemptCount
├── NextAttemptAt
├── SentAt
└── LastError

发票定稿时,在同一个数据库事务中创建通知记录。后台任务再读取待发送记录并发送邮件。

发送失败后可以重试,但需要设置:

  • 最大重试次数;
  • 指数退避或固定退避;
  • 不可重试错误的处理方式;
  • 人工补发入口;
  • 防止多个工作进程重复发送的锁定机制。

“SMTP 接受了邮件”通常只能说明邮件已经提交,不能证明客户确实收到或阅读了邮件。因此,状态名称要准确,例如区分 Submitted、DeliveryFailed,并单独记录客户访问 PDF 的情况。

审计日志不能只依赖普通应用日志

普通日志可能轮转或被删除。重要的业务事件应写入专门的审计记录,包括:

  • 发票创建、定稿和作废;
  • 编号分配;
  • 金额或税额计算规则版本;
  • PDF 生成和重新生成;
  • 链接创建、撤销和访问;
  • 邮件发送、失败和人工补发;
  • 操作人、操作时间及原因。

不要把敏感信息原样写入日志。访问令牌、完整税号、银行卡信息和非必要的客户资料,应隐藏或排除。

为计算规则建立独立测试

金额计算应与数据库和 PDF 代码分开,写成可以进行单元测试的纯业务逻辑。测试至少要覆盖:

  • 零金额发票;
  • 单行和多行发票;
  • 折扣后金额;
  • 多种税率;
  • 免税项目;
  • 小数中点舍入;
  • 负数调整;
  • 分摊余数;
  • 多币种;
  • 月末和闰年;
  • 同一账期重复执行;
  • PDF 失败后重试;
  • 邮件失败后重试;
  • 两个任务同时为同一客户开票。

还应准备少量由财务人员手工核算的标准样例。自动测试不能只比较总额,还要核对每行净额、税额、舍入差额和最终应付金额。

.NET 3.5 项目的额外注意事项

.NET 3.5 已经过时,缺少现代运行时提供的许多安全、异步和库支持。如果遗留环境必须继续使用它,需要特别注意:

  • 确认操作系统、TLS 和加密协议仍符合当前安全要求;
  • 核实 PDF、邮件和数据库驱动是否继续支持该运行时;
  • 不要使用现代 C# 的 async、record、字符串插值等语法;
  • 把第三方库封装在接口后面,方便以后替换;
  • 避免让模板引擎、PDF 组件和业务计算紧密耦合;
  • 制定升级到受支持 .NET 版本的计划。

即使主体系统暂时不能升级,也可以考虑由受支持的独立服务负责文件存储、邮件发送或 PDF 渲染。不过,这种方案会增加部署和一致性管理成本,需要结合现有架构评估。

应优先投入的部分包括不可变的发票快照、精确的金额规则、数据库唯一约束、可恢复的状态机和完整的审计链。相比之下,PDF 版式是整个系统中较容易替换的一层。

备注:内容仅供参考。