PAYATHON 2026

refund 记录的 amount 应存为正数还是负数?

支付阿杰

结论

建议将 amount 存为带符号的账务金额:

  • 收款、增加余额:正数
  • 退款、冲销、减少余额:负数

type 字段则用于记录具体的业务含义。这样可以直接通过 SUM(amount) 计算余额,增加交易类型时,也不需要反复修改汇总公式。

需要统一的是记账方向。金额的正负号不应由页面如何展示来决定,退款也并非在所有场景下都必须是负数。

为什么推荐负数

汇总逻辑更简单

退款记录为负数后,余额可以直接求和:

balance = SUM(transactions.amount)

例如:

purchase = 100.00
refund   = -30.00
balance  = 70.00

如果退款存为正数,每次统计都需要根据交易类型解释金额:

balance = SUM(purchase.amount) - SUM(refund.amount)

随着交易类型增加,查询、报表和对账代码可能漏掉某个分支。

数据本身保留记账方向

只有 amount = 30.00 时,一旦脱离 type,就无法判断这笔金额会增加还是减少余额。amount = -30.00 本身仍保留了账务方向。

type 依然不可缺少。负数只能表示余额减少,无法说明这笔交易是退款、手续费、折扣还是人工调整。

展示方式不应改变存储模型

页面可能需要显示“退款金额 30.00”。这种情况下,在展示层取绝对值即可:

display_amount = abs(transaction.amount)

没必要为了省掉一次 abs(),改变底层数据的记账语义。

建议的实现方式

from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import models


class Transaction(models.Model):
    class Type(models.TextChoices):
        PURCHASE = "purchase", "Purchase"
        REFUND = "refund", "Refund"

    order = models.ForeignKey("Order", on_delete=models.PROTECT)
    amount = models.DecimalField(max_digits=18, decimal_places=2)
    type = models.CharField(max_length=20, choices=Type.choices)

    def clean(self):
        if self.type == self.Type.PURCHASE and self.amount <= Decimal("0"):
            raise ValidationError(
                {"amount": "Purchase amount must be greater than zero."}
            )

        if self.type == self.Type.REFUND and self.amount >= Decimal("0"):
            raise ValidationError(
                {"amount": "Refund amount must be less than zero."}
            )

    @property
    def display_amount(self):
        return abs(self.amount)

汇总余额:

from decimal import Decimal
from django.db.models import Sum


balance = (
    Transaction.objects
    .filter(order=order)
    .aggregate(total=Sum("amount"))["total"]
    or Decimal("0")
)

模板中可以使用专门的展示属性:

Refund: {{ transaction.display_amount }}

在写入入口统一转换符号

外部 API 或表单通常会用正数表示退款金额。不要让各个调用方自行处理正负号,而应在领域服务中统一转换:

from decimal import Decimal
from django.db import transaction as db_transaction


@db_transaction.atomic
def create_refund(*, order, refund_amount):
    refund_amount = Decimal(refund_amount)

    if refund_amount <= 0:
        raise ValueError("refund_amount must be greater than zero")

    return Transaction.objects.create(
        order=order,
        type=Transaction.Type.REFUND,
        amount=-refund_amount,
    )

接口层表达的是“退款 30 元”,存储层记录的是“余额减少 30 元”,两层的职责由此分开。

必须注意的事项

不要混用两套符号语义

同一种交易类型不能同时出现以下两种数据:

type = refund, amount = -30.00
type = refund, amount =  30.00

如果退款金额既可以是正数,也可以是负数,SUM(amount) 和按类型统计的结果都可能失真。除了应用层校验,还应在数据库支持的情况下增加 CHECK 约束。

明确 balance 的观察视角

金额的正负取决于从哪个账户观察。对商户账户来说,购买可能是正数,退款是负数;对消费者账户来说,方向可能相反。

因此需要先定义:

正数 = 增加哪个账户的余额
负数 = 减少哪个账户的余额

如果系统同时维护商户、消费者和平台等多个账户,单个 Transaction.amount 通常不足以表达完整的资金流向。此时应考虑复式记账,让每笔业务生成多条分录,并保证借贷平衡。

不要修改原交易来表示退款

退款应写成一条新记录,并关联被退款的原交易。例如,可以增加:

refunded_transaction = models.ForeignKey(
    "self",
    null=True,
    blank=True,
    on_delete=models.PROTECT,
)

这种设计可以处理部分退款、多次退款、审计和对账。通常还需要校验累计退款金额,确保它不超过原交易允许退款的金额。

金额必须使用定点小数

不要使用 float 存储货币金额。数据库应使用 DECIMAL,Django 使用 DecimalField,Python 使用 Decimal。如果系统支持多币种,还需要存储 currency,不同币种的金额不能直接汇总。

对于简单的单账户交易模型,退款存为负数、展示时取绝对值,通常更稳定,也更容易扩展。

备注:内容仅供参考。