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

216 lines
7.6 KiB
Go

package payments
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
func (s *Service) refundDependencies() (RefundRepository, RefundProvider, error) {
repo, repoOK := s.repo.(RefundRepository)
provider, providerOK := s.provider.(RefundProvider)
if !s.Options().CanRecharge || !repoOK || !providerOK {
return nil, nil, ErrUnavailable
}
return repo, provider, nil
}
func (s *Service) AdminList(ctx context.Context, organizationID string, limit, offset int) (AdminOrdersPage, error) {
repo, ok := s.repo.(RefundRepository)
if !ok {
return AdminOrdersPage{}, ErrUnavailable
}
if limit < 1 || limit > 100 || offset < 0 {
return AdminOrdersPage{}, ErrInvalidAmount
}
orders, err := repo.ListAdminPaymentOrders(ctx, organizationID, limit+1, offset)
if err != nil {
return AdminOrdersPage{}, err
}
page := AdminOrdersPage{Orders: orders, Enabled: s.Options().CanRecharge}
if len(orders) > limit {
page.Orders = orders[:limit]
page.HasMore = true
}
return page, nil
}
func (s *Service) CreateRefund(ctx context.Context, orderID, operatorID, requestKey, reason string, amountFen int64) (Refund, error) {
repo, provider, err := s.refundDependencies()
if err != nil {
return Refund{}, err
}
reason = strings.TrimSpace(reason)
if orderID == "" || operatorID == "" || !requestKeyPattern.MatchString(requestKey) || reason == "" || len([]byte(reason)) > 80 || amountFen <= 0 {
return Refund{}, ErrInvalidAmount
}
order, err := s.repo.GetPaymentOrder(ctx, orderID)
if err != nil {
return Refund{}, err
}
if order.Status != StatusPaid || order.TransactionID == "" || order.MchID != s.config.MchID || order.AppID != s.config.AppID || order.Currency != "CNY" || amountFen > order.AmountFen {
return Refund{}, ErrConflict
}
id, err := newOrderID()
if err != nil {
return Refund{}, err
}
now := s.now().UTC()
wanted := Refund{ID: id, OrderID: orderID, OrganizationID: order.OrganizationID, OperatorID: operatorID,
RequestKey: requestKey, MchID: order.MchID, TransactionID: order.TransactionID,
AmountFen: amountFen, TotalFen: order.AmountFen, Currency: order.Currency, Reason: reason,
Status: RefundPending, CreatedAt: now, UpdatedAt: now}
refund, err := repo.CreatePaymentRefund(ctx, wanted)
if err != nil {
return Refund{}, err
}
if refund.OrderID != wanted.OrderID || refund.OrganizationID != wanted.OrganizationID || refund.OperatorID != wanted.OperatorID || refund.RequestKey != wanted.RequestKey || refund.MchID != wanted.MchID || refund.TransactionID != wanted.TransactionID || refund.AmountFen != wanted.AmountFen || refund.TotalFen != wanted.TotalFen || refund.Currency != wanted.Currency || refund.Reason != wanted.Reason {
return Refund{}, ErrConflict
}
if refund.Status != RefundPending {
return refund, nil
}
if refund.ID != wanted.ID {
return refund, nil // A repeated request waits for reconciliation of the same remote ID.
}
// The durable refund ID is the WeChat out_refund_no. Retrying it never creates a second refund.
txn, err := provider.CreateRefund(ctx, refund)
if err != nil {
var rejected *RefundRejectionError
if errors.As(err, &rejected) {
return rejectRefund(ctx, repo, refund.ID, rejected.Reason)
}
return refund, nil
} // Remote acceptance can be ambiguous; reconciliation queries this exact ID.
return s.recordRefund(ctx, repo, refund, txn)
}
func (s *Service) GetRefund(ctx context.Context, id string) (Refund, error) {
repo, ok := s.repo.(RefundRepository)
if !ok {
return Refund{}, ErrUnavailable
}
return repo.GetPaymentRefund(ctx, id)
}
func (s *Service) ListRefunds(ctx context.Context, orderID string) ([]Refund, error) {
repo, ok := s.repo.(RefundRepository)
if !ok {
return nil, ErrUnavailable
}
return repo.ListPaymentRefunds(ctx, orderID)
}
func (s *Service) RefundNotify(ctx context.Context, request *http.Request) error {
repo, provider, err := s.refundDependencies()
if err != nil {
return err
}
txn, err := provider.ParseRefundNotification(ctx, request)
if err != nil {
return err
}
refund, err := repo.GetPaymentRefund(ctx, txn.ID)
if err != nil {
return err
}
_, err = s.recordRefund(ctx, repo, refund, txn)
return err
}
func (s *Service) recordRefund(ctx context.Context, repo RefundRepository, refund Refund, txn RefundTransaction) (Refund, error) {
if txn.ID != refund.ID || txn.OrderID != refund.OrderID || txn.MchID != refund.MchID || txn.MchID != s.config.MchID ||
txn.TransactionID != refund.TransactionID || txn.ProviderID == "" || txn.AmountFen != refund.AmountFen ||
txn.TotalFen != refund.TotalFen || txn.Currency != refund.Currency ||
(txn.Status != RefundProcessing && txn.Status != RefundSuccess && txn.Status != RefundClosed && txn.Status != RefundAbnormal) ||
(txn.Status == RefundSuccess && txn.SuccessTime.IsZero()) {
return Refund{}, ErrVerification
}
order, err := s.repo.GetPaymentOrder(ctx, txn.OrderID)
if err != nil {
return Refund{}, err
}
if order.Status != StatusPaid || order.ID != refund.OrderID || order.MchID != refund.MchID || order.TransactionID != refund.TransactionID || order.AmountFen != refund.TotalFen || order.Currency != refund.Currency || order.OrganizationID != refund.OrganizationID {
return Refund{}, ErrVerification
}
return repo.RecordPaymentRefund(ctx, txn)
}
func (s *Service) reconcileRefunds(ctx context.Context) error {
repo, provider, err := s.refundDependencies()
if err != nil {
return nil
} // Existing payment-only providers have no refund queue.
refunds, err := repo.PendingPaymentRefunds(ctx, 100)
if err != nil {
return err
}
var failures []error
for _, refund := range refunds {
if ctx.Err() != nil {
return ctx.Err()
}
if s.now().Sub(refund.UpdatedAt) < refundQueryInterval(s.now().Sub(refund.CreatedAt)) {
continue
}
current, err := repo.TouchPaymentRefund(ctx, refund.ID)
if err != nil {
failures = append(failures, fmt.Errorf("refund %s: %w", refund.ID, err))
continue
}
if current.Status == RefundClosed || current.Status == RefundSuccess || current.Status == RefundRejected {
continue
}
txn, err := provider.QueryRefund(ctx, current.ID)
if errors.Is(err, ErrRemoteNotFound) {
// An earlier create could have timed out before receipt; the same out_refund_no is safe to retry.
order, orderErr := s.repo.GetPaymentOrder(ctx, current.OrderID)
if orderErr != nil || order.AppID != s.config.AppID || order.MchID != s.config.MchID {
failures = append(failures, fmt.Errorf("refund %s: %w", current.ID, ErrVerification))
continue
}
txn, err = provider.CreateRefund(ctx, current)
var rejected *RefundRejectionError
if errors.As(err, &rejected) {
if _, rejectErr := rejectRefund(ctx, repo, current.ID, rejected.Reason); rejectErr != nil {
failures = append(failures, fmt.Errorf("refund %s rejection: %w", current.ID, rejectErr))
}
continue
}
}
if err != nil {
failures = append(failures, fmt.Errorf("refund %s: %w", current.ID, err))
continue
}
if _, err = s.recordRefund(ctx, repo, current, txn); err != nil {
failures = append(failures, fmt.Errorf("refund %s: %w", current.ID, err))
}
}
return errors.Join(failures...)
}
func rejectRefund(ctx context.Context, repo RefundRepository, id, reason string) (Refund, error) {
refund, err := repo.RejectPaymentRefund(ctx, id, reason)
if errors.Is(err, ErrConflict) {
// A signed provider callback can win this race. Return its recorded state.
latest, getErr := repo.GetPaymentRefund(ctx, id)
if getErr == nil && latest.Status != RefundPending {
return latest, nil
}
}
return refund, err
}
func refundQueryInterval(age time.Duration) time.Duration {
if age < 5*time.Minute {
return time.Minute
}
if age < time.Hour {
return 2 * time.Minute
}
return 5 * time.Minute
}