PAYATHON 2026

如何为 bills 与 payments 设计 Object Oriented Design?

支付老李

结论

建议建立独立的账务分配记录,明确保存 Payment 与 Bill 的对应关系。Bill 的剩余金额、付款状态和账户总余额,则根据这些账务记录动态汇总。

保存记录和实时计算并不冲突。比较稳妥的设计是:

  • Bill 记录应付金额。
  • Payment 记录实际收到或支付的资金。
  • PaymentAllocation 记录某笔 Payment 分配给某张 Bill 的金额。
  • 余额属于派生值,可以在查询时计算,遇到性能问题时也可以缓存。
  • 原始账务记录尽量不要直接修改或删除,出现错误时通过冲销记录纠正。

需要注意的是,只有将付款应用到具体账单时,才创建 PaymentAllocation。创建 Bill 或 Payment 本身不需要同时创建映射记录。

为什么需要独立的分配对象

Bill 和 Payment 往往不是简单的一对一关系:

  • 一张账单可以分多次支付。
  • 一笔付款可以同时支付多张账单。
  • 付款可能暂时没有指定账单,成为账户余额或预付款。
  • 付款金额可能超过账单的剩余金额。
  • 已完成的分配可能需要撤销或重新分配。

所以,两者实际构成多对多关系,而且关系中还包含一个必要属性,也就是本次分配的金额。

Bill 1 ─── n PaymentAllocation n ─── 1 Payment

PaymentAllocation 并不是为了迎合数据库结构而增加的冗余表。它记录的是实际存在的业务事实。例如:

Payment P1001 中的 300 元被用于支付 Bill B2001。

这条事实需要长期保存,也需要支持审计和追踪。

推荐的数据模型

下面是一个简化的关系数据库设计:

CREATE TABLE bills (
    id              BIGINT PRIMARY KEY,
    account_id      BIGINT NOT NULL,
    amount_due      DECIMAL(19, 4) NOT NULL,
    currency        CHAR(3) NOT NULL,
    issued_at       TIMESTAMP NOT NULL,
    due_at          TIMESTAMP,
    status          VARCHAR(20) NOT NULL
);

CREATE TABLE payments (
    id              BIGINT PRIMARY KEY,
    account_id      BIGINT NOT NULL,
    amount          DECIMAL(19, 4) NOT NULL,
    currency        CHAR(3) NOT NULL,
    received_at     TIMESTAMP NOT NULL,
    status          VARCHAR(20) NOT NULL
);

CREATE TABLE payment_allocations (
    id              BIGINT PRIMARY KEY,
    bill_id         BIGINT NOT NULL REFERENCES bills(id),
    payment_id      BIGINT NOT NULL REFERENCES payments(id),
    amount          DECIMAL(19, 4) NOT NULL,
    created_at      TIMESTAMP NOT NULL,
    CHECK (amount > 0)
);

CREATE INDEX idx_allocations_bill
    ON payment_allocations(bill_id);

CREATE INDEX idx_allocations_payment
    ON payment_allocations(payment_id);

如果同一笔付款对同一张账单只能有一条分配记录,还可以添加:

ALTER TABLE payment_allocations
ADD CONSTRAINT uq_payment_bill UNIQUE (payment_id, bill_id);

如果系统允许多次分配、冲销或重新分配,则不应添加这个唯一约束。此时可以通过分配状态、冲销记录或账务分录区分不同操作。

余额如何计算

一张账单的剩余金额可以表示为:

remaining = bill.amount_due - SUM(valid allocations)

对应的查询如下:

SELECT
    b.id,
    b.amount_due,
    b.amount_due - COALESCE(SUM(pa.amount), 0) AS remaining_amount
FROM bills b
LEFT JOIN payment_allocations pa
    ON pa.bill_id = b.id
WHERE b.id = :bill_id
GROUP BY b.id, b.amount_due;

账单是否付清也应根据余额判断:

remaining_amount = 0  -> 已付清
remaining_amount > 0  -> 未付清或部分支付
remaining_amount < 0  -> 超额支付,通常意味着分配规则存在问题

如果业务不允许超额分配,应在写入 PaymentAllocation 时直接阻止,而不是等到查询余额时再处理。

账户的未付总额可以这样计算:

SELECT
    COALESCE(SUM(b.amount_due), 0)
    - COALESCE((
        SELECT SUM(pa.amount)
        FROM payment_allocations pa
        JOIN bills allocated_bill ON allocated_bill.id = pa.bill_id
        WHERE allocated_bill.account_id = :account_id
    ), 0) AS outstanding_balance
FROM bills b
WHERE b.account_id = :account_id;

实际项目还需要根据状态排除已取消的账单、失败的付款、无效分配和已冲销记录。

面向对象建模

不要让 Bill 遍历整个系统的付款历史。它只需要处理与自身有关的分配记录,或者由专门的 repository 返回汇总结果。

下面是一个简化的 Java 示例:

import java.math.BigDecimal;
import java.util.List;

public final class Bill {
    private final long id;
    private final BigDecimal amountDue;

    public Bill(long id, BigDecimal amountDue) {
        if (amountDue.signum() < 0) {
            throw new IllegalArgumentException("amountDue must not be negative");
        }

        this.id = id;
        this.amountDue = amountDue;
    }

    public long getId() {
        return id;
    }

    public BigDecimal getAmountDue() {
        return amountDue;
    }

    public BigDecimal remainingAmount(List<PaymentAllocation> allocations) {
        BigDecimal allocated = allocations.stream()
            .map(PaymentAllocation::getAmount)
            .reduce(BigDecimal.ZERO, BigDecimal::add);

        return amountDue.subtract(allocated);
    }

    public boolean isPaid(List<PaymentAllocation> allocations) {
        return remainingAmount(allocations).signum() == 0;
    }
}
import java.math.BigDecimal;

public final class PaymentAllocation {
    private final long billId;
    private final long paymentId;
    private final BigDecimal amount;

    public PaymentAllocation(
        long billId,
        long paymentId,
        BigDecimal amount
    ) {
        if (amount.signum() <= 0) {
            throw new IllegalArgumentException("amount must be positive");
        }

        this.billId = billId;
        this.paymentId = paymentId;
        this.amount = amount;
    }

    public long getBillId() {
        return billId;
    }

    public long getPaymentId() {
        return paymentId;
    }

    public BigDecimal getAmount() {
        return amount;
    }
}

创建分配记录的规则适合放在 application service 或 domain service 中:

public final class PaymentAllocationService {
    private final BillRepository billRepository;
    private final PaymentRepository paymentRepository;
    private final PaymentAllocationRepository allocationRepository;

    public PaymentAllocationService(
        BillRepository billRepository,
        PaymentRepository paymentRepository,
        PaymentAllocationRepository allocationRepository
    ) {
        this.billRepository = billRepository;
        this.paymentRepository = paymentRepository;
        this.allocationRepository = allocationRepository;
    }

    public void allocate(
        long paymentId,
        long billId,
        BigDecimal amount
    ) {
        Bill bill = billRepository.getByIdForUpdate(billId);
        Payment payment = paymentRepository.getByIdForUpdate(paymentId);

        BigDecimal billRemaining =
            allocationRepository.getRemainingAmount(billId);

        BigDecimal paymentAvailable =
            allocationRepository.getUnallocatedAmount(paymentId);

        if (amount.signum() <= 0) {
            throw new IllegalArgumentException("amount must be positive");
        }

        if (amount.compareTo(billRemaining) > 0) {
            throw new IllegalStateException(
                "allocation exceeds bill remaining amount"
            );
        }

        if (amount.compareTo(paymentAvailable) > 0) {
            throw new IllegalStateException(
                "allocation exceeds payment available amount"
            );
        }

        if (!bill.getCurrency().equals(payment.getCurrency())) {
            throw new IllegalStateException("currency mismatch");
        }

        allocationRepository.insert(
            new PaymentAllocation(billId, paymentId, amount)
        );
    }
}

这里的 getByIdForUpdate 表示分配过程需要适当的并发控制。具体做法取决于数据库和事务隔离级别,不一定要使用悲观锁。

是否应该保存 remainingAmount

通常不应把 remainingAmount 作为唯一事实来源,因为它可以根据账单金额和有效分配记录计算出来。

如果同时保存以下三个值:

Bill.amountDue
PaymentAllocation.amount
Bill.remainingAmount

那么每次分配都必须同时修改后两个值。只要其中一次更新失败,就可能出现:

amountDue - SUM(allocations) != remainingAmount

一旦发生这种情况,系统很难确定哪个值才是正确的。

因此,权威数据应当是原始业务事实:

  • 账单产生了多少应付金额;
  • 实际收到了多少付款;
  • 付款如何分配;
  • 哪些操作后来被撤销。

remainingAmount、isPaid 和账户总余额都是这些事实的投影。

派生值也不需要每次都由应用程序加载完整历史并逐条计算。数据库可以使用 SUM 直接聚合,而且只会扫描与当前账单有关的分配记录。为 bill_id 建立索引后,这和遍历整个 transaction history 并不是一回事。

性能不足时再增加投影或缓存

如果数据量很大,而且余额查询十分频繁,可以维护单独的余额投影:

CREATE TABLE bill_balances (
    bill_id             BIGINT PRIMARY KEY REFERENCES bills(id),
    allocated_amount    DECIMAL(19, 4) NOT NULL,
    remaining_amount    DECIMAL(19, 4) NOT NULL,
    updated_at          TIMESTAMP NOT NULL
);

写入分配记录时,在同一个数据库事务中更新投影:

BEGIN;

INSERT INTO payment_allocations (
    id,
    bill_id,
    payment_id,
    amount,
    created_at
) VALUES (
    :id,
    :bill_id,
    :payment_id,
    :amount,
    CURRENT_TIMESTAMP
);

UPDATE bill_balances
SET allocated_amount = allocated_amount + :amount,
    remaining_amount = remaining_amount - :amount,
    updated_at = CURRENT_TIMESTAMP
WHERE bill_id = :bill_id
  AND remaining_amount >= :amount;

COMMIT;

必须检查 UPDATE 实际影响了多少行。如果影响零行,可能是余额不足、记录不存在,也可能发生了并发竞争。此时应回滚事务。

在这种设计中:

  • payment_allocations 是权威账务记录。
  • bill_balances 是用于快速读取的投影。
  • 投影损坏后,可以根据权威记录重新构建。

不能反过来只保存余额而丢弃分配记录,否则系统无法解释余额是如何形成的。

事务历史与账务分录的区别

如果 transaction history 只包含 Bill created、Payment received 等业务事件,它无法完整说明一笔付款被用于哪张账单。系统仍然需要记录付款分配事件。

可以采用以下事件形式:

BillIssued
PaymentReceived
PaymentAllocated
PaymentAllocationReversed
BillCancelled

系统再通过重放事件计算状态。这属于 event sourcing,适用于审计、时间点还原和事件追踪要求很高的系统,但实现复杂度也会明显增加。

对大多数账单系统来说,关系表加事务已经足够:

bills
payments
payment_allocations

没有必要只为计算余额而引入完整的 event sourcing。

不要随意修改已经入账的记录

账务系统通常不仅要保存最终状态,还要说明过去发生了什么。已经确认的付款分配如果需要纠正,最好增加冲销记录,不要直接覆盖原记录。

例如,原分配为 100 元:

Allocation: +100

撤销时追加:

Reversal: -100

有效分配金额仍然通过求和得到:

SUM(+100, -100) = 0

数据库既可以采用带正负号的分录模型,也可以使用 reversal_of_id 指向被冲销的记录。具体选择取决于审计要求,但原则相同:保留历史。

必须处理的业务约束

实现时至少要明确以下规则:

  1. 金额类型

    不要使用 float 或 double 表示货币。数据库使用 DECIMAL,代码使用 BigDecimal、整数最小货币单位,或者专门的 Money value object。

  2. 币种

    金额必须带有币种。不同币种不能直接相加或分配。如果允许换汇,需要单独记录汇率、换汇时间和换汇结果。

  3. 并发分配

    两个请求可能同时读取同一份剩余余额,随后都成功分配。余额检查和记录写入必须处于同一个事务中,并配合行锁、原子条件更新或合适的事务隔离级别。

  4. 幂等性

    支付平台可能重复发送通知。Payment 应保存外部交易 ID 并建立唯一约束,防止同一笔付款被重复登记。

  5. 失败与待确认付款

    只有已经确认的付款才能参与有效余额计算。pending、failed、reversed 等状态都需要有明确的语义。

  6. 舍入规则

    税费、汇率或按比例分摊可能产生小数差异。舍入精度和舍入模式必须由业务规则确定,不能依赖编程语言或数据库的默认行为。

  7. 退款和贷项

    退款、credit note 和普通付款不是同一个业务概念。它们可以共用账务分录基础设施,但领域模型仍应保留各自的含义。

一个实用的分层方式

系统职责可以这样划分:

Bill / Payment
    保存各自的业务属性和局部规则

PaymentAllocation
    表达一笔付款如何应用到一张账单

PaymentAllocationService
    校验并执行分配、撤销和重新分配

Repository
    持久化对象并执行余额聚合查询

BillBalanceProjection
    在确有性能需求时提供快速读取

这样既不会把所有逻辑都塞进 Bill,也不会让数据库表成为缺少业务含义的数据容器。

最终建议

可以先建立 Bill、Payment 和 PaymentAllocation 三个核心模型,并把分配记录作为权威账务数据。余额由数据库聚合计算,配合正确的索引,通常不需要遍历完整历史。

只有监控结果证明聚合查询已经成为性能瓶颈时,再增加余额投影、缓存或定期快照。即使采用这些优化,也要保留原始分配记录,以便审计、核对和重新计算。

通用原则并不是余额必须保存,或者必须实时计算,而是:

保存不能丢失的业务事实,根据事实派生当前状态。需要优化性能时,可以缓存派生结果,但不能让缓存取代账务事实。

备注:内容仅供参考。