如何设计支持定期费用与部分付款的 payments database?
结论
现有设计的方向没有完全错,但 SCHEDULED_PAYMENTS 和 LEDGER 的职责不够明确,难以准确回答三个问题:应该收什么、实际收了多少,以及这笔钱支付了哪些费用。
建议把收费系统拆成四个核心概念:
- 报名关系(enrollment):学生何时加入、何时退出某个课程。
- 应收费用(charge):某月学费或一次性报名费,应收多少、何时到期。
- 实际收款(payment):学校实际收到的钱。
- 付款分配(payment_allocation):一笔收款分别冲抵了哪些应收费用。
charge 和 payment 不能合并。部分付款、一笔钱支付多个月的费用、退款和费用减免,都需要用单独的分配记录或调整记录来表示。
推荐的数据模型
下面的示例使用 PostgreSQL 风格的 SQL,并假设系统目前只使用一种货币。如果需要支持多币种,应在费用表和付款表中增加 currency_code,同时禁止不同币种之间直接冲抵。
CREATE TABLE student (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name varchar(200) NOT NULL
);
CREATE TABLE course (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name varchar(200) NOT NULL,
inscription_fee numeric(12, 2) NOT NULL CHECK (inscription_fee >= 0),
monthly_fee numeric(12, 2) NOT NULL CHECK (monthly_fee >= 0)
);
CREATE TABLE enrollment (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
student_id bigint NOT NULL REFERENCES student(id),
course_id bigint NOT NULL REFERENCES course(id),
enrolled_on date NOT NULL,
withdrawn_on date,
status varchar(20) NOT NULL
CHECK (status IN ('active', 'completed', 'withdrawn')),
CHECK (withdrawn_on IS NULL OR withdrawn_on >= enrolled_on)
);
enrollment 不能省略。同一个学生可能退出后重新报名,也可能重复修读同一门课程。因此,费用应关联某一次具体的报名,而不能只关联 student_id 和 course_id。
每一笔已经确定的应收费用都单独保存:
CREATE TABLE charge (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
enrollment_id bigint NOT NULL REFERENCES enrollment(id),
charge_type varchar(20) NOT NULL
CHECK (charge_type IN ('inscription', 'monthly', 'other')),
period_start date,
period_end date,
due_on date NOT NULL,
description varchar(500) NOT NULL,
original_amount numeric(12, 2) NOT NULL CHECK (original_amount >= 0),
status varchar(20) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'cancelled')),
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (
(period_start IS NULL AND period_end IS NULL)
OR
(period_start IS NOT NULL
AND period_end IS NOT NULL
AND period_end >= period_start)
)
);
学校实际收到的钱记录在 payment 中:
CREATE TABLE payment (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
student_id bigint NOT NULL REFERENCES student(id),
received_on date NOT NULL,
amount numeric(12, 2) NOT NULL CHECK (amount > 0),
payment_method varchar(30),
reference_no varchar(100),
status varchar(20) NOT NULL DEFAULT 'posted'
CHECK (status IN ('posted', 'voided')),
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);
一笔付款可以冲抵多笔费用,一笔费用也可以通过多次付款结清,所以两者是多对多关系:
CREATE TABLE payment_allocation (
payment_id bigint NOT NULL REFERENCES payment(id),
charge_id bigint NOT NULL REFERENCES charge(id),
amount numeric(12, 2) NOT NULL CHECK (amount > 0),
PRIMARY KEY (payment_id, charge_id)
);
例如,某月学费为 1,000 元,学生先支付 400 元,之后再支付 600 元。这时可以写入两条 payment,并分别创建对应的 payment_allocation。费用记录不需要增加“部分付款”状态,因为余额可以直接计算出来。
如何处理费用减免
不要修改课程的标准价格。course.monthly_fee 表示课程的默认收费标准,并不等于某个学生最终需要支付的金额。
费用减免通常有两种建模方式。
简单方案:生成费用时直接使用优惠后的金额
如果只需要知道学生最终应付多少,可以让 charge.original_amount 直接保存减免后的金额,再额外记录标准金额和减免原因:
ALTER TABLE charge
ADD COLUMN list_amount numeric(12, 2)
CHECK (list_amount IS NULL OR list_amount >= 0),
ADD COLUMN discount_reason varchar(500);
例如,标准月费为 1,000 元,减免 200 元:
list_amount = 1000.00
original_amount = 800.00
discount_reason = '家庭经济困难减免'
这种方式实现简单,但不便于记录多次调整、减免撤销或完整的审计历史。
可审计方案:单独记录费用调整
如果财务记录需要保留完整的变更历史,可以增加调整表:
CREATE TABLE charge_adjustment (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
charge_id bigint NOT NULL REFERENCES charge(id),
amount numeric(12, 2) NOT NULL CHECK (amount <> 0),
reason varchar(500) NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);
具体约定如下:
- 正数表示增加应收。
- 负数表示减免或贷记。
- 不直接修改原始费用。
- 每次调整都通过新增记录完成。
某笔费用的实际应收金额为:
original_amount + SUM(charge_adjustment.amount)
定期费用如何生成
“每月收费”是一条生成规则,而每个月实际产生的应收费用是独立的业务事实。不要只保存一个 monthly_fee,然后在每次查询时根据日历临时推算全部欠费。
可以按下面的流程处理:
- 学生报名时生成一次性报名费。
- 根据学校日历生成每个收费周期的月费。
- 每条月费都有自己的
period_start、period_end和due_on。 - 使用唯一约束,防止定时任务重复生成费用。
例如:
CREATE UNIQUE INDEX uq_monthly_charge
ON charge (enrollment_id, charge_type, period_start)
WHERE charge_type = 'monthly' AND status <> 'cancelled';
生成一笔月费的示例:
INSERT INTO charge (
enrollment_id,
charge_type,
period_start,
period_end,
due_on,
description,
original_amount
)
VALUES (
42,
'monthly',
DATE '2026-09-01',
DATE '2026-09-30',
DATE '2026-09-10',
'2026 年 9 月课程费用',
1000.00
);
如果收费周期与自然月不同,应以学校日历为准。最好再建立 billing_period 表,用来保存每个学期的收费周期和截止日期,不要依赖数据库按自然月计算。
查询哪些费用已付、未付或逾期
可以先建立费用余额视图,将付款分配和费用调整聚合起来:
CREATE VIEW charge_balance AS
SELECT
c.id AS charge_id,
c.enrollment_id,
c.charge_type,
c.period_start,
c.period_end,
c.due_on,
c.description,
c.status,
c.original_amount
+ COALESCE(a.adjustment_amount, 0) AS amount_due,
COALESCE(p.paid_amount, 0) AS amount_paid,
c.original_amount
+ COALESCE(a.adjustment_amount, 0)
- COALESCE(p.paid_amount, 0) AS balance
FROM charge c
LEFT JOIN (
SELECT charge_id, SUM(amount) AS adjustment_amount
FROM charge_adjustment
GROUP BY charge_id
) a ON a.charge_id = c.id
LEFT JOIN (
SELECT pa.charge_id, SUM(pa.amount) AS paid_amount
FROM payment_allocation pa
JOIN payment p ON p.id = pa.payment_id
WHERE p.status = 'posted'
GROUP BY pa.charge_id
) p ON p.charge_id = c.id;
查询某个学生的全部费用和付款情况:
SELECT
cb.charge_id,
co.name AS course_name,
cb.description,
cb.due_on,
cb.amount_due,
cb.amount_paid,
cb.balance,
CASE
WHEN cb.status = 'cancelled' THEN 'cancelled'
WHEN cb.balance <= 0 THEN 'paid'
WHEN cb.amount_paid > 0 AND cb.due_on < CURRENT_DATE THEN 'partially_paid_overdue'
WHEN cb.amount_paid > 0 THEN 'partially_paid'
WHEN cb.due_on < CURRENT_DATE THEN 'overdue'
ELSE 'unpaid'
END AS payment_state
FROM charge_balance cb
JOIN enrollment e ON e.id = cb.enrollment_id
JOIN course co ON co.id = e.course_id
WHERE e.student_id = 123
ORDER BY cb.due_on, cb.charge_id;
只查询已经到期但尚未结清的费用:
SELECT
cb.charge_id,
cb.description,
cb.due_on,
cb.amount_due,
cb.amount_paid,
cb.balance
FROM charge_balance cb
JOIN enrollment e ON e.id = cb.enrollment_id
WHERE e.student_id = 123
AND cb.status = 'open'
AND cb.due_on <= CURRENT_DATE
AND cb.balance > 0
ORDER BY cb.due_on;
查询学生尚未分配的付款余额:
SELECT
p.id,
p.received_on,
p.amount,
p.amount - COALESCE(SUM(pa.amount), 0) AS unallocated_amount
FROM payment p
LEFT JOIN payment_allocation pa ON pa.payment_id = p.id
WHERE p.student_id = 123
AND p.status = 'posted'
GROUP BY p.id, p.received_on, p.amount
HAVING p.amount - COALESCE(SUM(pa.amount), 0) > 0;
学生中途退出时如何处理
学生退出时,应更新对应的 enrollment,不要删除学生、报名记录或历史付款:
UPDATE enrollment
SET withdrawn_on = DATE '2026-10-15',
status = 'withdrawn'
WHERE id = 42;
之后再按照学校的退费规则处理相关费用:
- 停止生成尚未产生的未来月费。
- 已经生成但尚未生效的月费,可以标记为
cancelled。 - 已经到期的费用通常继续保留。
- 如果需要按比例收费,则新增调整记录,冲减不需要支付的部分。
- 如果学生已经多付,可以记录退款,也可以将多付金额保留为未分配余额。
不要直接删除已经入账的费用、付款或分配记录,否则以后很难解释余额变化的原因。实际系统在处理作废、退款和冲正时,通常会使用反向记录或明确的状态字段来保留审计轨迹。
还需要注意的约束
数据库应防止分配金额超过付款金额,也应避免将一笔付款分配给其他学生的费用。这些约束涉及多张表,普通 CHECK 无法实现,通常需要在事务中校验,必要时还可以使用触发器。
一次付款分配的完整操作应放在同一个事务中:
BEGIN;
-- 锁定付款,避免并发重复分配
SELECT id, amount
FROM payment
WHERE id = 9001
AND status = 'posted'
FOR UPDATE;
-- 应用层在这里校验:
-- 1. payment 与 charge 属于同一学生
-- 2. 本次分配不超过付款的未分配余额
-- 3. 本次分配不超过费用的未结余额
INSERT INTO payment_allocation (payment_id, charge_id, amount)
VALUES (9001, 501, 400.00);
COMMIT;
金额应使用 numeric/decimal,或者用整数保存最小货币单位,不能使用 float。以下字段也应建立索引:
CREATE INDEX ix_enrollment_student
ON enrollment (student_id);
CREATE INDEX ix_charge_enrollment_due
ON charge (enrollment_id, due_on);
CREATE INDEX ix_payment_student_date
ON payment (student_id, received_on);
CREATE INDEX ix_allocation_charge
ON payment_allocation (charge_id);
这套设计不需要增加一个复杂的“万能账本”,重点是清楚区分应收、实收和冲抵关系。定期费用表现为多条独立应收,部分付款表现为部分分配,费用减免通过调整记录表达,学生退出则由报名状态和截止日期控制。大多数余额和逾期查询都可以建立在统一的 charge_balance 视图上。
备注:内容仅供参考。
