feat(billing): 人工退款入口 —— paid 单冲销积分+置 refunded(幂等)
PAYMENT_DESIGN §5 承诺却只留了 OrderRefunded 常量、无入口。补齐全链路: - store.RefundOrder:订单 CAS(paid→refunded) 为主闸(重复退 changed=false 幂等), 同事务记 adjust 负分录(ref=订单号)+ 回退物化余额。照抄 MarkOrderPaid 双闸范式; 新增 idx_ledger_refund_ref 部分唯一索引(kind='adjust' AND ref<>'')做账本级兜底, 与 grant 索引对称、不与 admin 手工校正(ref 空)冲突。 - 积分若已消费,回退后余额可为负(人工退款预期,账本仍自洽,后续消费被硬拦截)。 - handler AdminRefundOrder + POST /admin/orders/:id/refund(admin 组已挂 Audit 留痕); 真渠道钱款原路退回需 admin 另在商户后台操作,本地仅冲销积分与订单态(不接自动退款 API)。 - admin 订单流加「退款」按钮(仅 paid 单可见,二次确认+填原因)。 - 测试:RefundOrder 冲销+幂等、只退 paid 两个不变量测试(sqlite 真 DB,余额=账本之和)。 gateway build/vet/test 全绿,admin tsc 干净。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -249,6 +249,55 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
|
||||
return changed, err
|
||||
}
|
||||
|
||||
// RefundOrder 人工退款(PAYMENT_DESIGN §5:admin 发起 → 订单置 refunded + 记 adjust 负分录 + 回退余额)。
|
||||
// 只退 paid 单。幂等照抄 MarkOrderPaid 范式:**订单 CAS(paid→refunded) 为主闸**——RowsAffected==0
|
||||
// 表示已退过(或非 paid),直接幂等成功,不重复冲销。credit_ledger 的 (kind='adjust', ref=订单号)
|
||||
// 部分唯一索引兜底(与 grant 的双闸对称)。
|
||||
// 注:若积分已被消费,回退后物化余额可能为负——这是人工退款的预期(钱退了、积分早花了),
|
||||
// 账本仍自洽(余额 = SUM(ledger)),后续消费被积分硬拦截挡住直到再充值。
|
||||
// 返回 changed=false 表示这单本就无需退(已退/未支付/不存在),幂等。
|
||||
func (p *Postgres) RefundOrder(ctx context.Context, orderID, operatorUserID, memo string) (bool, error) {
|
||||
if p.db == nil {
|
||||
return false, errStoreDisabled
|
||||
}
|
||||
if strings.TrimSpace(orderID) == "" {
|
||||
return false, errors.New("订单号必填")
|
||||
}
|
||||
if strings.TrimSpace(memo) == "" {
|
||||
memo = "人工退款"
|
||||
}
|
||||
changed := false
|
||||
err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Model(&PaymentOrder{}).
|
||||
Where("id = ? AND status = ?", orderID, OrderPaid).
|
||||
Update("status", OrderRefunded)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil // 已退 / 非 paid / 不存在 —— 幂等,不冲销
|
||||
}
|
||||
var o PaymentOrder
|
||||
if err := tx.First(&o, "id = ?", orderID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 负分录:kind=adjust、ref=订单号(不撞 grant 的 kind=grant 同 ref;adjust 部分唯一索引兜住重复退)。
|
||||
if err := tx.Create(&CreditLedger{
|
||||
TenantID: o.TenantID, Kind: LedgerAdjust, CreditsMicro: -o.CreditsMicro, Ref: o.ID,
|
||||
Memo: "退款 " + o.Channel + ":" + memo + "(操作人 " + operatorUserID + ")",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
|
||||
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro - ?", o.CreditsMicro)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
changed = true
|
||||
return nil
|
||||
})
|
||||
return changed, err
|
||||
}
|
||||
|
||||
// PendingWechatOrders 捞出所有 pending 的微信订单(掉单补偿定时器扫描用)。
|
||||
// 只取微信单:兑换码单核销即 paid,永不 pending,不需要查单。按创建时间升序,先补老单。
|
||||
func (p *Postgres) PendingWechatOrders(ctx context.Context, limit int) ([]PaymentOrder, error) {
|
||||
|
||||
Reference in New Issue
Block a user