216 lines
7.6 KiB
Go
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
|
|
}
|