如何在 transaction table 中正确存储 payments 和 refunds?
结论
如果这张表只记录单一视角下的现金流,而且 refund 始终表示资金流出,那么把 refund 存为负数没有问题,SUM(amount) 也能正确算出净额。
但在需要审计、对账或生成正式财务数据的系统中,更稳妥的做法是:
- 将交易金额保存为非负的绝对值;
- 用明确的字段表示交易类型或资金方向;
- 在查询或生成列中计算带符号金额;
- 正式账务采用复式记账模型,不用正负号代替 debit 和 credit。
判断一种设计是否合理,不能只看负数本身。更重要的是每个字段是否只有一种清楚的含义,以及数据库能否阻止互相矛盾的数据。
为什么两种存法都可能成立
下面两条记录表达的信息基本相同:
amount = 50.00, type = 'refund'
amount = -50.00, type = 'refund'
第一种将金额大小与业务含义分开保存。第二种则让金额符号同时表示资金方向。
如果系统规则很简单,第二种设计可以正常工作:
SELECT SUM(amount) AS net_amount
FROM transactions;
麻烦出现在同时保留 type 和金额符号时。两者都在表达方向,数据库就可能出现下面这些组合:
amount = 50.00, type = refund
amount = -50.00, type = refund
amount = -50.00, type = payment
没有约束时,很难判断哪条记录有误。报表按 type 计算和按符号计算,还可能得出不同的结果。
因此,不要让 type 和符号各自决定资金方向。应该选定一套规则,并通过数据库约束保证所有数据都遵守它。
推荐方案:金额保存为非负值,方向单独表达
支付业务表可以将金额定义为绝对值:
CREATE TABLE transactions (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
transaction_type varchar(20) NOT NULL,
amount decimal(19, 4) NOT NULL,
currency_code char(3) NOT NULL,
original_transaction_id bigint NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_transaction_type
CHECK (transaction_type IN ('payment', 'refund')),
CONSTRAINT chk_amount_positive
CHECK (amount > 0),
CONSTRAINT fk_original_transaction
FOREIGN KEY (original_transaction_id)
REFERENCES transactions(id),
CONSTRAINT chk_refund_reference
CHECK (
transaction_type <> 'refund'
OR original_transaction_id IS NOT NULL
)
);
在这个设计中:
amount只表示金额大小;transaction_type表示业务类型;original_transaction_id指明 refund 对应哪笔 payment;- 金额方向根据业务规则在查询时计算。
计算净额时,不必把负数写入表中:
SELECT SUM(
CASE transaction_type
WHEN 'payment' THEN amount
WHEN 'refund' THEN -amount
END
) AS net_amount
FROM transactions;
也可以创建视图,让所有报表统一使用带符号金额:
CREATE VIEW transaction_amounts AS
SELECT
id,
transaction_type,
amount,
currency_code,
CASE transaction_type
WHEN 'payment' THEN amount
WHEN 'refund' THEN -amount
END AS signed_amount
FROM transactions;
之后的查询就很直接:
SELECT currency_code, SUM(signed_amount) AS net_amount
FROM transaction_amounts
GROUP BY currency_code;
这种设计保留了清楚的业务语义,同时也方便使用 SUM()。
如果决定直接保存负数
对于只关心单一方向现金流的简单系统,也可以采用以下规则:
- payment 必须大于零;
- refund 必须小于零;
amount表示从固定观察视角看到的净资金变化。
这些规则必须写入数据库约束:
CREATE TABLE transactions (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
transaction_type varchar(20) NOT NULL,
amount decimal(19, 4) NOT NULL,
currency_code char(3) NOT NULL,
CONSTRAINT chk_transaction_type
CHECK (transaction_type IN ('payment', 'refund')),
CONSTRAINT chk_amount_matches_type
CHECK (
(transaction_type = 'payment' AND amount > 0)
OR
(transaction_type = 'refund' AND amount < 0)
)
);
这样便能直接计算净额:
SELECT currency_code, SUM(amount) AS net_amount
FROM transactions
GROUP BY currency_code;
采用这种方案时,正负方向必须始终不变。例如,需要明确 payment 表示“商家收到的钱”,而不是泛指所有支付行为。站在消费者、商家、支付平台或银行账户的不同视角,同一笔 payment 的符号可能正好相反。
还要避免下面这种写法:
SELECT
CASE transaction_type
WHEN 'refund' THEN -amount
ELSE amount
END
FROM transactions;
如果 refund 已经存为负数,再加一次负号就会变回正数。采用带符号存储后,所有调用方都必须遵守同一约定。
为什么正负数不能替代 debit 和 credit
“收入为正、支出为负”只是现金流的展示规则,并不等同于会计中的 debit 和 credit。
复式记账中的一项业务通常会影响至少两个账户。例如,收到客户支付的 100 元可以记为:
Debit Cash 100.00
Credit Accounts Receivable 100.00
向客户退款 50 元可能记为:
Debit Refund Liability 50.00
Credit Cash 50.00
debit 或 credit 如何影响账户余额,取决于账户类型。资产、负债、收入和费用等账户的规则不同,不能简单理解为“debit 永远为正”或“credit 永远为负”。
系统需要正式会计能力时,应分别建模业务和业务产生的会计分录:
CREATE TABLE journal_entries (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
occurred_at timestamp NOT NULL,
description varchar(500),
source_type varchar(50) NOT NULL,
source_id bigint NOT NULL
);
CREATE TABLE journal_postings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
journal_entry_id bigint NOT NULL
REFERENCES journal_entries(id),
account_id bigint NOT NULL,
side varchar(6) NOT NULL,
amount decimal(19, 4) NOT NULL,
currency_code char(3) NOT NULL,
CONSTRAINT chk_posting_side
CHECK (side IN ('debit', 'credit')),
CONSTRAINT chk_posting_amount
CHECK (amount > 0)
);
对于每个 journal_entry,同一币种的 debit 合计必须等于 credit 合计。这是一项跨行规则,通常需要由应用服务、延迟约束或数据库过程在事务提交前验证。普通的单行 CHECK 约束无法单独保证这一点。
支付表仍可保存 payment、refund 等业务事实,再由这些事实产生会计分录。两者用途不同,没有必要合并到同一张表中。
实际设计时还要处理的问题
Refund 应关联原始 payment
refund 通常不是孤立的负数,而是某笔 payment 的全部或部分退款。表中应保存原交易引用,并校验累计退款不超过允许退款的金额:
SELECT COALESCE(SUM(amount), 0) AS refunded_amount
FROM transactions
WHERE transaction_type = 'refund'
AND original_transaction_id = :payment_id;
发生并发退款时,简单地“先查询、再插入”可能产生竞争。额度检查和写入应放在同一个数据库事务中,并使用适当的行锁或同等效果的并发控制。
不要混合币种求和
下面的查询通常没有业务意义:
SELECT SUM(amount)
FROM transactions;
如果表中同时包含 CNY、USD 等币种,至少要按币种分组:
SELECT currency_code, SUM(signed_amount)
FROM transaction_amounts
GROUP BY currency_code;
如果需要跨币种汇总,还必须明确汇率、折算币种和汇率时间。
使用定点数类型
金额应使用 decimal 或 numeric,不要使用二进制浮点类型:
amount decimal(19, 4)
具体的精度和小数位数要根据币种、支付渠道及业务上限确定,没有一组参数适用于所有系统。
退款和冲正不要混为一谈
业务中可能存在:
- 客户主动退款;
- 支付失败后的撤销;
- 银行拒付或 chargeback;
- 手续费;
- 人工更正;
- 原交易冲正后重新记账。
这些操作对资金、业务状态和账务科目的影响可能不同,不能因为金额都在减少,就全部归入 refund。transaction_type 应描述真实的业务事件,而不只是金额的正负方向。
财务记录尽量采用追加和冲正
交易一旦进入对账或账务流程,就不宜直接修改原金额。通常会追加一笔退款、冲正或调整记录,并引用原记录。这样既能保留完整的审计轨迹,也能说明余额为什么发生变化。
如何选择
如果系统只是简单的收支流水表,而且金额方向有唯一且稳定的观察视角,可以保存带符号金额,但应通过数据库约束保证 type 与符号一致。
如果系统需要清楚的领域模型、退款关联、审计能力和更好的可维护性,建议将金额保存为非负值,另用字段保存业务类型或资金方向,再通过视图生成 signed_amount。
如果数据会用于总账、财务报表或其他正式会计处理,则应采用复式记账模型。业务交易记录事实,会计分录记录各账户的 debit 和 credit。单个带正负号的 amount 字段无法完整表达会计含义。
备注:内容仅供参考。
