286 lines
14 KiB
Go
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)
|
|
}
|
|
}
|