数据库中处理退款与 store credits 的最佳方式
结论
退款可以在统一资金流水中记为负数,但不能只插入一条无法追溯来源的 -amount 记录。
比较稳妥的设计是:
- 分别保存订单、支付、退款、礼品卡和 store credit 的业务信息。
- 单独维护统一的资金流水或会计分录,供对账和报表使用。
- 每笔退款都要关联原支付或原订单。
- 礼品卡和 store credit 不能简单算作负销售额。它们通常会先成为商家的负债,实际消费时再参与结算。
所以,重点不是”负数能不能放在同一张表里”,而是这张表能否准确记录交易类型、来源、状态和关联关系。
为什么只有 custid, orderid, amount 不够
业务简单时,这类表或许够用。但只要出现退款、部分退款、撤销、支付失败或多种支付方式,问题就会很快暴露出来。
比如,只看到一条 amount = -100 的记录,无法判断它属于哪种情况:
- 原路退款;
- 发放 store credit;
- 使用礼品卡;
- 手工调账;
- 支付撤销;
- 拒付或 chargeback;
- 数据录入错误。
一个订单还可能有多笔支付,也可能分两次退款。支付成功、支付授权、支付失败和退款处理中代表不同状态。把这些情况都压缩到订单表的一个金额字段里,会丢失很多必要信息。
推荐的数据结构
没有必要把所有业务信息塞进同一张表。业务表和统一流水可以分开设计。
订单表
订单表负责记录”卖了什么”,不应兼作银行流水。
CREATE TABLE orders (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
status VARCHAR(30) NOT NULL,
currency CHAR(3) NOT NULL,
subtotal_amount DECIMAL(19, 4) NOT NULL,
tax_amount DECIMAL(19, 4) NOT NULL,
total_amount DECIMAL(19, 4) NOT NULL,
created_at TIMESTAMP NOT NULL
);
实际系统一般还需要 order_items,用于保存商品、数量、成交单价、折扣和税费等明细。
支付表
一个订单可以没有支付,也可以对应一笔或多笔支付。
CREATE TABLE payments (
payment_id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
provider VARCHAR(50) NOT NULL,
provider_reference VARCHAR(255),
status VARCHAR(30) NOT NULL,
currency CHAR(3) NOT NULL,
amount DECIMAL(19, 4) NOT NULL CHECK (amount > 0),
paid_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (order_id) REFERENCES orders(order_id)
);
退款表
退款表中的金额可以保持为正数,因为表名和记录类型已经表明这是一笔退款。写入统一流水时,再把它记为资金流出。
CREATE TABLE refunds (
refund_id BIGINT PRIMARY KEY,
payment_id BIGINT NOT NULL,
order_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
status VARCHAR(30) NOT NULL,
currency CHAR(3) NOT NULL,
amount DECIMAL(19, 4) NOT NULL CHECK (amount > 0),
reason VARCHAR(255),
provider_reference VARCHAR(255),
idempotency_key VARCHAR(100) UNIQUE,
refunded_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (payment_id) REFERENCES payments(payment_id),
FOREIGN KEY (order_id) REFERENCES orders(order_id)
);
每笔退款都应关联具体的 payment_id。这样才能正确处理部分退款、多次退款,以及一个订单使用多种支付方式的情况。
统一资金流水
如果需要从一张表生成资金报表,可以维护一张只追加记录的流水表:
CREATE TABLE money_transactions (
transaction_id BIGINT PRIMARY KEY,
customer_id BIGINT,
order_id BIGINT,
transaction_type VARCHAR(30) NOT NULL,
status VARCHAR(30) NOT NULL,
currency CHAR(3) NOT NULL,
signed_amount DECIMAL(19, 4) NOT NULL,
source_type VARCHAR(30) NOT NULL,
source_id BIGINT NOT NULL,
occurred_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE (source_type, source_id)
);
示例记录:
transaction_type signed_amount source_type source_id
PAYMENT 100.00 PAYMENT 501
REFUND -30.00 REFUND 801
REFUND -70.00 REFUND 802
在同一张表中使用正负金额没有问题,但必须同时保存 transaction_type,不能只根据金额的正负判断业务含义。
source_type 和 source_id 用来定位原始业务记录。数据库很难为这种多态关联建立普通外键。如果需要严格的外键约束,可以为不同来源设置独立字段,或者改用会计分录模型。
礼品卡和 store credit 要单独处理
礼品卡、退款和 store credit 表面上都是”给客户一笔钱”,但业务含义不同。
原路退款
退款是把已经收取的资金退还给客户。它通常会减少净收款,并且需要关联原支付。
Store credit
Store credit 是客户只能在商店内部使用的余额。发放这笔余额时,通常不会立即产生新的现金流出,而是形成商家对客户的履约义务。
可以使用账户表和余额变动表来记录:
CREATE TABLE credit_accounts (
credit_account_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE (customer_id, currency)
);
CREATE TABLE credit_entries (
credit_entry_id BIGINT PRIMARY KEY,
credit_account_id BIGINT NOT NULL,
entry_type VARCHAR(30) NOT NULL,
amount DECIMAL(19, 4) NOT NULL,
order_id BIGINT,
refund_id BIGINT,
expires_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (credit_account_id)
REFERENCES credit_accounts(credit_account_id)
);
符号规则需要提前约定。例如:
ISSUE:正数,增加客户余额;REDEEM:负数,扣减消费余额;EXPIRE:负数,表示余额失效;ADJUSTMENT:按调整方向使用正数或负数。
可用明细汇总出当前余额:
SELECT
credit_account_id,
SUM(amount) AS available_balance
FROM credit_entries
GROUP BY credit_account_id;
在高并发场景中,可以额外保存余额快照,但明细流水仍要作为审计依据。扣减余额时应使用事务、行锁或条件更新,防止客户并发消费导致透支。
礼品卡
礼品卡最好使用独立账户,因为它可能具有以下特点:
- 不绑定具体客户;
- 可以转赠;
- 有独立卡号;
- 存在激活、冻结、过期和作废状态;
- 支持多次消费;
- 需要分别记录购买人和使用人。
从会计角度看,出售礼品卡通常不会立即确认商品销售收入,而是先形成合同负债。礼品卡被使用时,再根据实际业务确认收入。具体如何处理,取决于所在地区的法规和会计准则,需要由财务人员确认。
报表不要混用”销售额”和”收款额”
设计报表前,要先定义清楚指标。下面这些数字并不是一回事:
Gross sales 商品原始销售额
Discounts 折扣
Returns 退货对应的销售冲减
Net sales 净销售额
Payments 实际收款
Cash refunds 实际退回的现金
Store credit 内部余额的发放和消费
如果直接对所有流水执行 SUM(amount),得到的只是某种净资金变动,不一定等于财务意义上的净销售额。
支付和现金退款可以按下面的方式统计:
SELECT
currency,
SUM(
CASE
WHEN transaction_type = 'PAYMENT'
AND status = 'SUCCEEDED'
THEN signed_amount
WHEN transaction_type = 'REFUND'
AND status = 'SUCCEEDED'
THEN signed_amount
ELSE 0
END
) AS net_cash_collected
FROM money_transactions
GROUP BY currency;
不同币种不能直接相加。报表应按 currency 分组,或者使用本位币金额,并明确汇率来源和换算时间。
实施时需要注意的细节
不要覆盖历史记录
资金数据应尽量采用追加模式。记录出错时,应增加冲正或调整记录,不要直接修改或删除已经完成的支付和退款。
这样既能保留完整的审计链,也方便与支付服务商对账。
区分业务时间与记录时间
建议至少保存以下时间:
occurred_at:交易实际发生的时间;created_at:记录写入数据库的时间;settled_at:支付渠道完成结算的时间,按业务需要保存。
这三个时间可能并不相同。
防止重复退款
支付服务商的回调和客户端请求都可能重试。创建退款时应使用唯一的 idempotency_key,并为服务商交易号建立唯一约束。
系统还要检查累计成功退款是否超过原支付金额:
SELECT COALESCE(SUM(amount), 0) AS refunded_amount
FROM refunds
WHERE payment_id = :payment_id
AND status = 'SUCCEEDED';
这项检查必须放在数据库事务或其他可靠的并发控制机制中。只做一次查询再插入记录,在并发请求下仍可能出现超额退款。
不要使用浮点数保存金额
金额应使用 DECIMAL,也可以用最小货币单位的整数保存,例如以”分”为单位:
amount_minor BIGINT NOT NULL
使用整数时仍要保存币种,而且不同币种的最小单位不一定都是两位小数。
明确定义状态
建议明确区分以下状态:
PENDING
SUCCEEDED
FAILED
CANCELED
只有成功的支付或退款才能进入正式财务报表。处理中和失败的记录可以保留,但不能算作已经发生的资金变动。
是否需要复式记账
如果系统只处理简单订单,业务表加统一流水通常就够用了。
如果业务涉及多币种、税费、平台佣金、供应商结算、礼品卡负债、内部钱包或严格审计,更适合采用复式记账。每个业务事件至少生成两条借贷方向平衡的分录,并满足:
SUM(debit) = SUM(credit)
复式记账的实现成本更高,但面对复杂资金业务时,它比单个正负金额字段更可靠。
负数本身没有问题。需要避免的是只有金额,没有类型、来源和状态,也无法关联原交易的记录。业务数据分表、资金流水统一、历史记录只追加,会让商店类数据库的资金设计更可靠。
备注:内容仅供参考。
