Files
hfb_sys/backend/internal/modules/payment/late_paid_test.go
T
yml2213andCursor 590bf1cae3 fix(payment): 渠道已收款时不再把支付单回滚成支付中
租客在待支付超时或取消的最后几十秒完成付款时,订单先被取消,乐刷的支付
结果几秒后才到,入账被判定订单不可支付而拒收。原先订单推进和支付单更新
在同一个事务里,订单一报错整笔回滚,支付单停在支付中且 paid_at 为空:
钱已在渠道,系统却毫不知情。乐刷重试十次后放弃,之后既补不回入账(订单
不再是待支付),后台人工退款也发不出去(退款要求原支付单是已支付)。
日志保留期内 13 天出了 5 笔,合计 4085.60 元。

- 取消前核对渠道:超时取消、租客取消、客服关单都先查一次渠道,已收款并
  已推进订单就放弃取消,让支付正常入账而不是走退款。渠道异常时按未收款
  继续,避免渠道一抖订单全卡住
- 回调兜底记账:订单确实已结束时不再回滚,把支付单如实记成已支付并写入
  paid_at 与渠道单号,后台人工退款从此可用。退款仍由客服人工发起
- 客服关单:待支付订单按实际已收款减已退款的差额退,补上本次事故中关单
  不生成退款的直接原因
- 补齐日志上下文:回调入账失败原先只有 error="order cannot pay",现在带
  支付单号、订单号、订单状态和金额;新增 ERROR「渠道已收款但订单无法入账,
  需人工退款」作为唯一需人工介入的告警

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 09:23:25 +08:00

164 lines
6.2 KiB
Go

package payment
import (
"errors"
"testing"
"time"
"hfb_sys/backend/internal/model"
ordermodule "hfb_sys/backend/internal/modules/order"
)
// TestConfirmPaidSettlesPaymentWhenOrderAlreadyCancelled 钉住本次事故的根因:
// 渠道回调带着「已支付」到达,而订单已经被取消。原先订单侧报 ErrOrderCannotPay 会把
// 整笔事务回滚,支付单停在 paying 且 paid_at 为空,于是钱收了却既补不回入账
// (订单不再是待支付),也退不出去——后台人工退款要求原支付单是已支付。
// 现在必须如实把支付单记成已支付,人工退款才有的可退。
func TestConfirmPaidSettlesPaymentWhenOrderAlreadyCancelled(t *testing.T) {
db := setupPaymentTestDB(t)
repo := NewRepository(db, nil, ordermodule.NewRepository(db))
fixture := createPayableOrderFixture(t, db, "LP01", true)
if err := db.Model(&model.RentalOrder{}).Where("id = ?", fixture.Order.ID).
Updates(map[string]any{"status": "cancelled", "handoff_status": "cancelled"}).Error; err != nil {
t.Fatalf("cancel order failed: %v", err)
}
paidAt := time.Date(2026, 9, 15, 23, 22, 47, 0, time.UTC)
if err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", paidAt, map[string]string{
"provider_order_id": "PROVIDERLP01",
}, channelSourceNotify); err != nil {
t.Fatalf("confirmPaid() error = %v, want nil so the channel stops retrying", err)
}
var payment model.PaymentOrder
if err := db.First(&payment, fixture.Payment.ID).Error; err != nil {
t.Fatalf("find payment failed: %v", err)
}
if payment.Status != "paid" {
t.Fatalf("payment status = %q, want paid", payment.Status)
}
if payment.PaidAt == nil {
t.Fatal("payment paid_at = nil, want the channel pay time")
}
if payment.ProviderOrderID != "PROVIDERLP01" {
t.Fatalf("provider order id = %q, want PROVIDERLP01", payment.ProviderOrderID)
}
var order model.RentalOrder
if err := db.First(&order, fixture.Order.ID).Error; err != nil {
t.Fatalf("find order failed: %v", err)
}
if order.Status != "cancelled" {
t.Fatalf("order status = %q, want cancelled (payment must not revive it)", order.Status)
}
}
// TestConfirmPaidDoesNotAutoRefundUnpayableOrder 退款一律由客服人工发起,
// 入账路径不能自己动钱。
func TestConfirmPaidDoesNotAutoRefundUnpayableOrder(t *testing.T) {
db := setupPaymentTestDB(t)
repo := NewRepository(db, nil, ordermodule.NewRepository(db))
fixture := createPayableOrderFixture(t, db, "LP02", true)
if err := db.Model(&model.RentalOrder{}).Where("id = ?", fixture.Order.ID).
Update("status", "closed").Error; err != nil {
t.Fatalf("close order failed: %v", err)
}
if err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", time.Now(), map[string]string{
"provider_order_id": "PROVIDERLP02",
}, channelSourceNotify); err != nil {
t.Fatalf("confirmPaid() error = %v", err)
}
var refundCount int64
if err := db.Model(&model.PaymentOrder{}).
Where("order_id = ? AND biz_type <> ?", fixture.Order.ID, "order_pay").
Count(&refundCount).Error; err != nil {
t.Fatalf("count refunds failed: %v", err)
}
if refundCount != 0 {
t.Fatalf("refund payment count = %d, want 0 (refund must stay manual)", refundCount)
}
var order model.RentalOrder
if err := db.First(&order, fixture.Order.ID).Error; err != nil {
t.Fatalf("find order failed: %v", err)
}
if order.RefundStatus == "refunded" || order.RefundStatus == "refunding" {
t.Fatalf("order refund status = %q, want untouched", order.RefundStatus)
}
}
// TestConfirmPaidSettlementIsIdempotent 乐刷会重试十次同一个回调,重复落账不能出错。
func TestConfirmPaidSettlementIsIdempotent(t *testing.T) {
db := setupPaymentTestDB(t)
repo := NewRepository(db, nil, ordermodule.NewRepository(db))
fixture := createPayableOrderFixture(t, db, "LP03", true)
if err := db.Model(&model.RentalOrder{}).Where("id = ?", fixture.Order.ID).
Update("status", "cancelled").Error; err != nil {
t.Fatalf("cancel order failed: %v", err)
}
paidAt := time.Date(2026, 9, 15, 23, 22, 47, 0, time.UTC)
for i := 0; i < 3; i++ {
if err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", paidAt.Add(time.Duration(i)*time.Minute), map[string]string{
"provider_order_id": "PROVIDERLP03",
}, channelSourceNotify); err != nil {
t.Fatalf("confirmPaid() attempt %d error = %v", i+1, err)
}
}
var payment model.PaymentOrder
if err := db.First(&payment, fixture.Payment.ID).Error; err != nil {
t.Fatalf("find payment failed: %v", err)
}
if payment.Status != "paid" {
t.Fatalf("payment status = %q, want paid", payment.Status)
}
// 首次落账的时间必须保留,后续重试不能把 paid_at 往后推。
if payment.PaidAt == nil || !payment.PaidAt.UTC().Equal(paidAt) {
t.Fatalf("payment paid_at = %v, want %v", payment.PaidAt, paidAt)
}
}
// TestConfirmPaidKeepsErrorForUnrelatedFailures 只有「订单再也无法入账」才走落账。
// 其他错误仍要原样抛出,否则会把真正的故障当成已处理而静默掉。
func TestConfirmPaidKeepsErrorForUnrelatedFailures(t *testing.T) {
db := setupPaymentTestDB(t)
repo := NewRepository(db, nil, nil)
fixture := createPayableOrderFixture(t, db, "LP04", true)
err := repo.confirmPaid(t.Context(), &fixture.Payment, "paid", time.Now(), map[string]string{}, channelSourceNotify)
if !errors.Is(err, ErrDependencyUnavailable) {
t.Fatalf("confirmPaid() error = %v, want ErrDependencyUnavailable", err)
}
var payment model.PaymentOrder
if findErr := db.First(&payment, fixture.Payment.ID).Error; findErr != nil {
t.Fatalf("find payment failed: %v", findErr)
}
if payment.Status != "paying" {
t.Fatalf("payment status = %q, want paying", payment.Status)
}
}
func TestIsOrderUnpayable(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"订单已取消或已关单", ordermodule.ErrOrderCannotPay, true},
{"商品已被他人租走", ordermodule.ErrListingUnavailable, true},
{"依赖缺失", ErrDependencyUnavailable, false},
{"无错误", nil, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isOrderUnpayable(tc.err); got != tc.want {
t.Fatalf("isOrderUnpayable(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}