PAYATHON 2026

如何规范化按次付款与月结全额付款的账单表

支付阿杰

明确结论

不需要为 paid_in_full 或 monthly_statement 分别创建“付款类型表”。

建议采用下面的设计:

  • delivery 记录已经完成的交付。
  • invoice 记录向客户发出的付款请求。
  • invoice_line 关联账单与一个或多个交付。
  • payment 记录实际收到的款项。
  • payment_allocation 记录每笔付款冲抵了哪些账单。
  • 只有确实需要生成月度对账单时,才使用 monthly_statement。它是某个客户在一个结算周期内的汇总快照,不是付款方式。
  • “已全额付款”应根据账单金额和已分配的付款金额计算,也可以保存为需要同步维护的状态字段,不必单独建表。

按次付款与月结付款的差别,主要是一张 invoice 中包含多少个 delivery:

  • 按次付款:每次交付生成一张账单。
  • 月结付款:把一个月内的多个交付合并到一张账单。

因此,这两种模式可以共用同一套核心表结构。

为什么不应建立 paid_in_full 表

“已全额付款”是账单的当前状态,不是独立的业务实体。

一张账单是否付清,可以通过以下数据计算:

outstanding_amount = invoice_total - allocated_payment_total

当:

outstanding_amount <= 0

账单状态为 paid。部分支付时为 partially_paid,尚未支付时为 open。

如果另外建立 paid_in_full 表,系统就会同时保存付款记录和付清记录,两者很容易失去同步。例如,付款退款后,对应的 paid_in_full 记录可能没有一并删除。

推荐的数据关系

核心关系如下:

customer
  ├── delivery
  ├── invoice
  │     └── invoice_line ── delivery
  ├── payment
  │     └── payment_allocation ── invoice
  └── monthly_statement

具体关系是:

  • 一个客户可以有多个交付。
  • 一张账单可以包含多个账单明细。
  • 每条账单明细可以对应一次交付。
  • 一笔付款可以冲抵多张账单。
  • 一张账单也可以由多笔付款分次付清。
  • 一份月度对账单可以汇总某个结算周期内的账单、付款和余额。

在 payment 与 invoice 之间增加 payment_allocation,可以处理部分付款、合并付款以及一次结清多张账单等情况。

表结构示例

以下示例省略了税务、折扣、退款和多币种等扩展字段:

CREATE TABLE customer (
    customer_id      BIGINT PRIMARY KEY,
    customer_name    VARCHAR(200) NOT NULL,
    billing_mode     VARCHAR(20) NOT NULL,
    CHECK (billing_mode IN ('PER_DELIVERY', 'MONTHLY'))
);

CREATE TABLE delivery (
    delivery_id      BIGINT PRIMARY KEY,
    customer_id      BIGINT NOT NULL,
    completed_at     TIMESTAMP NOT NULL,
    amount           DECIMAL(18, 2) NOT NULL,
    currency_code    CHAR(3) NOT NULL,
    FOREIGN KEY (customer_id) REFERENCES customer(customer_id),
    CHECK (amount >= 0)
);

CREATE TABLE invoice (
    invoice_id       BIGINT PRIMARY KEY,
    customer_id      BIGINT NOT NULL,
    invoice_number   VARCHAR(50) NOT NULL UNIQUE,
    issued_at        TIMESTAMP NOT NULL,
    due_at           TIMESTAMP NOT NULL,
    billing_period_start DATE,
    billing_period_end   DATE,
    status           VARCHAR(20) NOT NULL,
    currency_code    CHAR(3) NOT NULL,
    total_amount     DECIMAL(18, 2) NOT NULL,
    FOREIGN KEY (customer_id) REFERENCES customer(customer_id),
    CHECK (status IN ('DRAFT', 'ISSUED', 'VOID')),
    CHECK (total_amount >= 0)
);

CREATE TABLE invoice_line (
    invoice_line_id  BIGINT PRIMARY KEY,
    invoice_id       BIGINT NOT NULL,
    delivery_id      BIGINT,
    description      VARCHAR(500) NOT NULL,
    quantity         DECIMAL(18, 4) NOT NULL DEFAULT 1,
    unit_price       DECIMAL(18, 2) NOT NULL,
    line_amount      DECIMAL(18, 2) NOT NULL,
    FOREIGN KEY (invoice_id) REFERENCES invoice(invoice_id),
    FOREIGN KEY (delivery_id) REFERENCES delivery(delivery_id),
    CHECK (quantity > 0),
    CHECK (line_amount >= 0)
);

CREATE TABLE payment (
    payment_id       BIGINT PRIMARY KEY,
    customer_id      BIGINT NOT NULL,
    received_at      TIMESTAMP NOT NULL,
    amount           DECIMAL(18, 2) NOT NULL,
    currency_code    CHAR(3) NOT NULL,
    payment_status   VARCHAR(20) NOT NULL,
    external_reference VARCHAR(100),
    FOREIGN KEY (customer_id) REFERENCES customer(customer_id),
    CHECK (amount > 0),
    CHECK (payment_status IN ('PENDING', 'SETTLED', 'FAILED', 'REFUNDED'))
);

CREATE TABLE payment_allocation (
    payment_id       BIGINT NOT NULL,
    invoice_id       BIGINT NOT NULL,
    allocated_amount DECIMAL(18, 2) NOT NULL,
    PRIMARY KEY (payment_id, invoice_id),
    FOREIGN KEY (payment_id) REFERENCES payment(payment_id),
    FOREIGN KEY (invoice_id) REFERENCES invoice(invoice_id),
    CHECK (allocated_amount > 0)
);

如果一次交付只能开票一次,可以添加唯一约束:

ALTER TABLE invoice_line
ADD CONSTRAINT uq_invoice_line_delivery UNIQUE (delivery_id);

这个约束只适用于每次交付不会拆分到多张账单的情况。如果业务允许分批计费,就不能添加该约束,而应在明细中记录本次计费的数量或金额。

两种付款模式如何落库

按次付款

每完成一次交付,就创建一张 invoice,其中加入一条对应的 invoice_line:

Delivery D1
    → Invoice I1
        → InvoiceLine L1

客户付款后,创建 payment,再通过 payment_allocation 将款项分配给 I1。

月结全额付款

月末找出该客户在当前结算周期内尚未开票的交付,再创建一张合并账单:

Delivery D1 ┐
Delivery D2 ├→ Invoice I2
Delivery D3 ┘

I2 包含三条 invoice_line。客户一次性付款后,通过一条 payment_allocation 将全部款项分配给 I2。

也就是说,“按次”和“月结”改变的是开票批次,付款模型不需要随之改变。

月度对账单是否需要单独建表

首先要区分“月度发票”和“月度对账单”。

如果客户每月收到的是一张包含当月全部交付的正式账单,直接使用 invoice 即可,并填写:

billing_period_start
billing_period_end

这种情况通常不需要 monthly_statement。

如果对账单还需要展示期初余额、本月新账单、付款、退款、调整和期末余额,则可以单独建表:

CREATE TABLE monthly_statement (
    statement_id       BIGINT PRIMARY KEY,
    customer_id        BIGINT NOT NULL,
    period_start       DATE NOT NULL,
    period_end         DATE NOT NULL,
    opening_balance    DECIMAL(18, 2) NOT NULL,
    charges_amount     DECIMAL(18, 2) NOT NULL,
    payments_amount    DECIMAL(18, 2) NOT NULL,
    closing_balance    DECIMAL(18, 2) NOT NULL,
    generated_at       TIMESTAMP NOT NULL,
    FOREIGN KEY (customer_id) REFERENCES customer(customer_id),
    UNIQUE (customer_id, period_start, period_end)
);

如果还要保存对账单中包含了哪些账单,可以增加关联表:

CREATE TABLE statement_invoice (
    statement_id     BIGINT NOT NULL,
    invoice_id       BIGINT NOT NULL,
    PRIMARY KEY (statement_id, invoice_id),
    FOREIGN KEY (statement_id)
        REFERENCES monthly_statement(statement_id),
    FOREIGN KEY (invoice_id)
        REFERENCES invoice(invoice_id)
);

对账单应关联客户、结算周期和被汇总的账单,不能直接充当付款记录。付款仍然只记录在 payment 中。

查询账单付款状态

账单余额可以根据有效的付款分配计算:

SELECT
    i.invoice_id,
    i.total_amount,
    COALESCE(SUM(
        CASE
            WHEN p.payment_status = 'SETTLED'
            THEN pa.allocated_amount
            ELSE 0
        END
    ), 0) AS paid_amount,
    i.total_amount - COALESCE(SUM(
        CASE
            WHEN p.payment_status = 'SETTLED'
            THEN pa.allocated_amount
            ELSE 0
        END
    ), 0) AS outstanding_amount,
    CASE
        WHEN COALESCE(SUM(
            CASE
                WHEN p.payment_status = 'SETTLED'
                THEN pa.allocated_amount
                ELSE 0
            END
        ), 0) >= i.total_amount
        THEN 'PAID'
        WHEN COALESCE(SUM(
            CASE
                WHEN p.payment_status = 'SETTLED'
                THEN pa.allocated_amount
                ELSE 0
            END
        ), 0) > 0
        THEN 'PARTIALLY_PAID'
        ELSE 'OPEN'
    END AS payment_state
FROM invoice i
LEFT JOIN payment_allocation pa
    ON pa.invoice_id = i.invoice_id
LEFT JOIN payment p
    ON p.payment_id = pa.payment_id
GROUP BY i.invoice_id, i.total_amount;

实际系统也可以缓存 paid_amount 或付款状态,以提高查询效率,但这些字段仍然属于派生数据。更新付款、撤销付款或处理退款时,必须在同一事务中同步维护,避免缓存状态与明细记录不一致。

需要特别注意的事项

  • 不要把“月结”建模成一种付款方式。月结决定何时开票,以及哪些交付需要合并开票;付款发生在账单生成之后。
  • 不要先为每次交付开票,又在月底把同一批交付计入新的月度发票,否则会重复确认应收款。
  • 如果月底文件只是汇总已有账单,应将其称为对账单;如果该文件本身会产生新的应收款,就应作为合并发票。
  • invoice_line 应保存开票时的描述、单价、税额等快照。后续修改 delivery 的价格,不应影响已经正式出具的账单。
  • 多币种场景下,分配付款时必须校验币种,或者明确保存汇率和折算金额。
  • 退款、贷项通知单和坏账核销不应通过删除付款来处理,而应增加相应的反向交易记录。
  • 月末批量开票必须具备幂等性,避免同一个 delivery 因任务重试而被重复开票。
  • 如果客户可以预付款,可以允许 payment.amount 大于已分配金额。未分配的部分作为客户余额保留,之后再分配给新的账单。

备注:内容仅供参考。