PAYATHON 2026

数据库中处理退款与 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)

复式记账的实现成本更高,但面对复杂资金业务时,它比单个正负金额字段更可靠。

负数本身没有问题。需要避免的是只有金额,没有类型、来源和状态,也无法关联原交易的记录。业务数据分表、资金流水统一、历史记录只追加,会让商店类数据库的资金设计更可靠。

备注:内容仅供参考。