如何规范化按次付款与月结全额付款的账单表
明确结论
不需要为 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大于已分配金额。未分配的部分作为客户余额保留,之后再分配给新的账单。
备注:内容仅供参考。
