PAYATHON 2026

如何在 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 字段无法完整表达会计含义。

备注:内容仅供参考。