Files
NianAIGC/backend/internal/postgres/refunds_integration_test.go
T
2026-10-12 13:57:23 +08:00

286 lines
14 KiB
Go

package postgres
import (
"context"
"errors"
"os"
"sync"
"testing"
"time"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments"
"github.com/jackc/pgx/v5/pgxpool"
)
// Run against a disposable, migrated database only. Never uses DATABASE_URL.
func TestRefundSettlementInDisposablePostgres(t *testing.T) {
url := os.Getenv("NIAN_TEST_REFUND_DATABASE_URL")
if url == "" {
t.Skip("set NIAN_TEST_REFUND_DATABASE_URL to a disposable migrated database")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
db := NewDatabase(Config{Backend: BackendPostgres}, &pgxPoolAdapter{pool: pool})
if err := db.Readiness(ctx); err != nil {
t.Fatal(err)
}
_, err = pool.Exec(ctx, `INSERT INTO public.platform_organizations(id,name,archive_owner_id)
VALUES ('refund_test_org','refund test org','refund_test_archive');
INSERT INTO public.platform_users(id,phone,display_name,role,organization_id,status,password_hash,password_salt)
VALUES ('refund_test_admin','13900000901','Refund Admin','super_admin',NULL,'active','hash','salt'),
('refund_test_orgadmin','13900000902','Org Admin','organization_admin','refund_test_org','active','hash','salt')`)
if err != nil {
t.Fatal(err)
}
order, err := db.CreatePaymentOrder(ctx, payments.Order{ID: "refund_test_order", OrganizationID: "refund_test_org", AccountID: "refund_test_orgadmin", RequestKey: "refund_test_pay_key", MchID: "refund_test_mch", AppID: "refund_test_app", AmountFen: 500, Currency: "CNY", ExpiresAt: time.Now().Add(15 * time.Minute)})
if err != nil {
t.Fatal(err)
}
_, err = db.SettlePaymentOrder(ctx, payments.Transaction{OrderID: order.ID, MchID: order.MchID, AppID: order.AppID, TransactionID: "refund_test_payment_tx", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 500, SuccessTime: time.Now()})
if err != nil {
t.Fatal(err)
}
// Spend 400 before refund; successful refund must leave a negative balance.
rows, err := pool.Query(ctx, PostWalletEntrySQL, "refund_test_charge", "refund_test_org", nil, nil, "charge", int64(-400), "CNY", "refund_test_charge_key", "consumption", nil)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
} // consume the SQL function result
if err := rows.Err(); err != nil {
t.Fatal(err)
}
rows.Close()
create := func(id, key string, amount int64, actor string) (payments.Refund, error) {
return db.CreatePaymentRefund(ctx, payments.Refund{ID: id, OrderID: order.ID, OperatorID: actor, RequestKey: key, AmountFen: amount, Reason: "customer request"})
}
if _, err := create("refund_test_bad", "refund_test_bad_key", 100, "refund_test_orgadmin"); !errors.Is(err, payments.ErrConflict) {
t.Fatalf("non-super-admin refund: %v", err)
}
first, err := create("refund_test_refund", "refund_test_refund_key", 500, "refund_test_admin")
if err != nil {
t.Fatal(err)
}
if first.TransactionID != "refund_test_payment_tx" || first.MchID != "refund_test_mch" {
t.Fatalf("refund snapshot: %+v", first)
}
second, err := create("refund_test_replay", "refund_test_refund_key", 500, "refund_test_admin")
if err != nil || second.ID != first.ID {
t.Fatalf("idempotent refund: %+v %v", second, err)
}
if _, err := create("refund_test_other", "refund_test_other_key", 1, "refund_test_admin"); !errors.Is(err, payments.ErrInvalidAmount) {
t.Fatalf("over-refund reserved: %v", err)
}
remote := payments.RefundTransaction{ID: first.ID, OrderID: order.ID, MchID: order.MchID, TransactionID: "refund_test_payment_tx", ProviderID: "wx_refund_test_id", Status: payments.RefundSuccess, Currency: "CNY", AmountFen: 500, TotalFen: 500, SuccessTime: time.Now()}
var wg sync.WaitGroup
result := make(chan error, 8)
for i := 0; i < 8; i++ {
wg.Add(1)
go func() { defer wg.Done(); _, err := db.RecordPaymentRefund(ctx, remote); result <- err }()
}
wg.Wait()
close(result)
for err := range result {
if err != nil {
t.Fatalf("concurrent callback: %v", err)
}
}
var balance, gross, charged int64
if err := pool.QueryRow(ctx, `SELECT balance_fen,total_recharged_fen,total_charged_fen FROM public.billing_wallets WHERE organization_id='refund_test_org'`).Scan(&balance, &gross, &charged); err != nil {
t.Fatal(err)
}
if balance != -400 || gross != 500 || charged != 400 {
t.Fatalf("balance=%d gross=%d charged=%d", balance, gross, charged)
}
var count int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM public.billing_ledger WHERE organization_id='refund_test_org' AND kind='recharge_refund'`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("refund ledger count=%d", count)
}
// Ordinary debits remain guarded even after the refund debit caused debt.
rows, err = pool.Query(ctx, PostWalletEntrySQL, "refund_test_charge2", "refund_test_org", nil, nil, "charge", int64(-1), "CNY", "refund_test_charge_key2", "consumption", nil)
if err == nil {
for rows.Next() {
}
err = rows.Err()
rows.Close()
}
if err == nil {
t.Fatal("ordinary charge accepted negative balance")
}
admin, err := db.ListAdminPaymentOrders(ctx, "", 10, 0)
if err != nil || len(admin) != 1 || admin[0].RefundedFen != 500 || admin[0].RefundableFen != 0 {
t.Fatalf("admin orders: %+v %v", admin, err)
}
// A confirmed close frees the reservation. A genuine delayed SUCCESS still
// settles, provided the total of settled and reserved refunds fits the payment.
order2, err := db.CreatePaymentOrder(ctx, payments.Order{ID: "refund_test_order2", OrganizationID: "refund_test_org", AccountID: "refund_test_orgadmin", RequestKey: "refund_test_pay_key2", MchID: "refund_test_mch", AppID: "refund_test_app", AmountFen: 500, Currency: "CNY", ExpiresAt: time.Now().Add(15 * time.Minute)})
if err != nil {
t.Fatal(err)
}
_, err = db.SettlePaymentOrder(ctx, payments.Transaction{OrderID: order2.ID, MchID: order2.MchID, AppID: order2.AppID, TransactionID: "refund_test_payment_tx2", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 500, SuccessTime: time.Now()})
if err != nil {
t.Fatal(err)
}
late, err := db.CreatePaymentRefund(ctx, payments.Refund{ID: "refund_test_late", OrderID: order2.ID, OperatorID: "refund_test_admin", RequestKey: "refund_test_late_key", AmountFen: 350, Reason: "late success"})
if err != nil {
t.Fatal(err)
}
lateRemote := payments.RefundTransaction{ID: late.ID, OrderID: order2.ID, MchID: order2.MchID, TransactionID: "refund_test_payment_tx2", ProviderID: "wx_refund_test_late", Status: payments.RefundClosed, Currency: "CNY", AmountFen: 350, TotalFen: 500}
if closed, err := db.RecordPaymentRefund(ctx, lateRemote); err != nil || closed.Status != payments.RefundClosed {
t.Fatalf("close refund: %+v %v", closed, err)
}
replacement, err := db.CreatePaymentRefund(ctx, payments.Refund{ID: "refund_test_replacement", OrderID: order2.ID, OperatorID: "refund_test_admin", RequestKey: "refund_test_replacement_key", AmountFen: 200, Reason: "replacement"})
if err != nil {
t.Fatalf("reserve after close: %v", err)
}
lateRemote.Status = payments.RefundSuccess
if settled, err := db.RecordPaymentRefund(ctx, lateRemote); err != nil || settled.Status != payments.RefundSuccess {
t.Fatalf("late refund success: %+v %v", settled, err)
}
if _, err := db.RecordPaymentRefund(ctx, lateRemote); err != nil {
t.Fatalf("late refund replay: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT balance_fen FROM public.billing_wallets WHERE organization_id='refund_test_org'`).Scan(&balance); err != nil {
t.Fatal(err)
}
if balance != -250 {
t.Fatalf("late success wallet balance=%d, want -250", balance)
}
if _, err := db.RejectPaymentRefund(ctx, late.ID, "明确拒绝"); !errors.Is(err, payments.ErrConflict) {
t.Fatalf("successful refund was rejected: %v", err)
}
rejectedReplacement, err := db.RejectPaymentRefund(ctx, replacement.ID, "微信明确拒绝退款")
if err != nil || rejectedReplacement.Status != payments.RefundRejected || rejectedReplacement.FailureReason != "微信明确拒绝退款" {
t.Fatalf("reject unsubmitted refund: %+v %v", rejectedReplacement, err)
}
if again, err := db.RejectPaymentRefund(ctx, replacement.ID, "different reason"); err != nil || again.FailureReason != "微信明确拒绝退款" {
t.Fatalf("rejected replay: %+v %v", again, err)
}
if replay, err := db.CreatePaymentRefund(ctx, payments.Refund{ID: "refund_test_replacement_again", OrderID: order2.ID, OperatorID: "refund_test_admin", RequestKey: "refund_test_replacement_key", AmountFen: 200, Reason: "replacement"}); err != nil || replay.ID != replacement.ID || replay.Status != payments.RefundRejected {
t.Fatalf("same request after rejection: %+v %v", replay, err)
}
var createWG sync.WaitGroup
createResults := make(chan error, 2)
for _, n := range []string{"a", "b"} {
createWG.Add(1)
go func(n string) {
defer createWG.Done()
_, err := db.CreatePaymentRefund(ctx, payments.Refund{ID: "refund_test_concurrent_" + n, OrderID: order2.ID, OperatorID: "refund_test_admin", RequestKey: "refund_test_concurrent_key_" + n, AmountFen: 100, Reason: "concurrent"})
createResults <- err
}(n)
}
createWG.Wait()
close(createResults)
var accepted, rejected int
for err := range createResults {
if err == nil {
accepted++
} else if errors.Is(err, payments.ErrInvalidAmount) {
rejected++
} else {
t.Fatalf("concurrent reservation: %v", err)
}
}
if accepted != 1 || rejected != 1 {
t.Fatalf("concurrent accepted=%d rejected=%d", accepted, rejected)
}
all, err := db.ListPaymentRefunds(ctx, order2.ID)
if err != nil {
t.Fatal(err)
}
for _, item := range all {
if item.Status != payments.RefundPending {
continue
}
processing := payments.RefundTransaction{ID: item.ID, OrderID: order2.ID, MchID: order2.MchID,
TransactionID: "refund_test_payment_tx2", ProviderID: "wx_refund_test_processing", Status: payments.RefundProcessing,
Currency: "CNY", AmountFen: item.AmountFen, TotalFen: 500}
if _, err := db.RecordPaymentRefund(ctx, processing); err != nil {
t.Fatalf("processing refund: %v", err)
}
if _, err := db.RejectPaymentRefund(ctx, item.ID, "明确拒绝"); !errors.Is(err, payments.ErrConflict) {
t.Fatalf("processing refund was rejected: %v", err)
}
break
}
order3, err := db.CreatePaymentOrder(ctx, payments.Order{ID: "refund_test_order3", OrganizationID: "refund_test_org", AccountID: "refund_test_orgadmin", RequestKey: "refund_test_pay_key3", MchID: "refund_test_mch", AppID: "refund_test_app", AmountFen: 100, Currency: "CNY", ExpiresAt: time.Now().Add(15 * time.Minute)})
if err != nil {
t.Fatal(err)
}
_, err = db.SettlePaymentOrder(ctx, payments.Transaction{OrderID: order3.ID, MchID: order3.MchID, AppID: order3.AppID, TransactionID: "refund_test_payment_tx3", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 100, SuccessTime: time.Now()})
if err != nil {
t.Fatal(err)
}
unexpected, err := db.CreatePaymentRefund(ctx, payments.Refund{ID: "refund_test_unexpected", OrderID: order3.ID, OperatorID: "refund_test_admin", RequestKey: "refund_test_unexpected_key", AmountFen: 100, Reason: "unexpected success"})
if err != nil {
t.Fatal(err)
}
if _, err := db.RejectPaymentRefund(ctx, unexpected.ID, "微信明确拒绝退款"); err != nil {
t.Fatal(err)
}
confirmed := payments.RefundTransaction{ID: unexpected.ID, OrderID: order3.ID, MchID: order3.MchID, TransactionID: "refund_test_payment_tx3", ProviderID: "wx_refund_test_unexpected", Status: payments.RefundSuccess, Currency: "CNY", AmountFen: 100, TotalFen: 100}
if recovered, err := db.RecordPaymentRefund(ctx, confirmed); err != nil || recovered.Status != payments.RefundSuccess || recovered.FailureReason != "" {
t.Fatalf("trusted success after rejection: %+v %v", recovered, err)
}
order4, err := db.CreatePaymentOrder(ctx, payments.Order{ID: "refund_test_order4", OrganizationID: "refund_test_org", AccountID: "refund_test_orgadmin", RequestKey: "refund_test_pay_key4", MchID: "refund_test_mch", AppID: "refund_test_app", AmountFen: 100, Currency: "CNY", ExpiresAt: time.Now().Add(15 * time.Minute)})
if err != nil {
t.Fatal(err)
}
_, err = db.SettlePaymentOrder(ctx, payments.Transaction{OrderID: order4.ID, MchID: order4.MchID, AppID: order4.AppID, TransactionID: "refund_test_payment_tx4", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 100, SuccessTime: time.Now()})
if err != nil {
t.Fatal(err)
}
recoveredRefund, err := db.CreatePaymentRefund(ctx, payments.Refund{ID: "refund_test_recovered", OrderID: order4.ID, OperatorID: "refund_test_admin", RequestKey: "refund_test_recovered_key", AmountFen: 100, Reason: "remote recovery"})
if err != nil {
t.Fatal(err)
}
if _, err := db.RejectPaymentRefund(ctx, recoveredRefund.ID, "微信明确拒绝退款"); err != nil {
t.Fatal(err)
}
progress := payments.RefundTransaction{ID: recoveredRefund.ID, OrderID: order4.ID, MchID: order4.MchID, TransactionID: "refund_test_payment_tx4", ProviderID: "wx_refund_test_recovered", Status: payments.RefundProcessing, Currency: "CNY", AmountFen: 100, TotalFen: 100}
if resumed, err := db.RecordPaymentRefund(ctx, progress); err != nil || resumed.Status != payments.RefundProcessing || resumed.FailureReason != "" {
t.Fatalf("trusted processing after rejection: %+v %v", resumed, err)
}
pending, err := db.PendingPaymentRefunds(ctx, 100)
if err != nil {
t.Fatal(err)
}
var queued bool
for _, item := range pending {
if item.ID == recoveredRefund.ID {
queued = true
}
}
if !queued {
t.Fatal("recovered processing refund missing from reconciliation queue")
}
progress.Status = payments.RefundAbnormal
if resumed, err := db.RecordPaymentRefund(ctx, progress); err != nil || resumed.Status != payments.RefundAbnormal || resumed.FailureReason != "" {
t.Fatalf("trusted abnormal after rejection: %+v %v", resumed, err)
}
_, err = pool.Exec(ctx, `INSERT INTO public.payment_orders
(id, organization_id, account_id, request_key, mch_id, app_id, amount_fen, currency, status, transaction_id, paid_at, expires_at)
SELECT 'refund_page_' || lpad(i::text, 3, '0'), 'refund_test_org', 'refund_test_orgadmin',
'refund_page_key_' || i, 'refund_test_mch', 'refund_test_app', 100, 'CNY', 'paid',
'refund_page_tx_' || i, now(), now() + interval '15 minutes'
FROM generate_series(1,105) AS i`)
if err != nil {
t.Fatal(err)
}
firstPage, err := db.ListAdminPaymentOrders(ctx, "", 101, 0)
if err != nil || len(firstPage) != 101 {
t.Fatalf("admin page limit+1: len=%d err=%v", len(firstPage), err)
}
nextPage, err := db.ListAdminPaymentOrders(ctx, "", 101, 100)
if err != nil || len(nextPage) != 9 {
t.Fatalf("admin page offset: len=%d err=%v", len(nextPage), err)
}
}