package payments import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "net/http" "regexp" "strings" "time" ) const queryInterval = 5 * time.Second var requestKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) type Service struct { repo Repository provider Provider config Config now func() time.Time } func NewService(repo Repository, provider Provider, config Config) *Service { if config.MinAmountFen == 0 { config.MinAmountFen = 100 } if config.MaxAmountFen == 0 { config.MaxAmountFen = 1000000 } if config.OrderTTL == 0 { config.OrderTTL = 15 * time.Minute } if config.PresetAmountsFen == nil { config.PresetAmountsFen = []int64{10000, 50000, 100000} } return &Service{repo: repo, provider: provider, config: config, now: time.Now} } func (s *Service) Options() Options { presets := make([]int64, len(s.config.PresetAmountsFen)) copy(presets, s.config.PresetAmountsFen) return Options{Enabled: s.config.Enabled, CanRecharge: s.config.Enabled && s.repo != nil && s.provider != nil, PresetAmountsFen: presets, MinAmountFen: s.config.MinAmountFen, MaxAmountFen: s.config.MaxAmountFen} } func (s *Service) Create(ctx context.Context, organizationID, accountID, requestKey string, amountFen int64) (Order, error) { if !s.Options().CanRecharge { return Order{}, ErrUnavailable } if organizationID == "" || accountID == "" || !requestKeyPattern.MatchString(requestKey) || amountFen < s.config.MinAmountFen || amountFen > s.config.MaxAmountFen { return Order{}, ErrInvalidAmount } id, err := newOrderID() if err != nil { return Order{}, err } now := s.now().UTC() wanted := Order{ID: id, OrganizationID: organizationID, AccountID: accountID, RequestKey: requestKey, MchID: s.config.MchID, AppID: s.config.AppID, AmountFen: amountFen, Currency: "CNY", Status: StatusPending, CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(s.config.OrderTTL)} order, err := s.repo.CreatePaymentOrder(ctx, wanted) if err != nil { return Order{}, err } if order.OrganizationID != organizationID || order.AccountID != accountID || order.RequestKey != requestKey || order.AmountFen != amountFen || order.MchID != s.config.MchID || order.AppID != s.config.AppID || order.Currency != "CNY" { return Order{}, ErrConflict } if order.Status != StatusPending || order.CodeURL != "" { return order, nil } if !s.now().Before(order.ExpiresAt) { return s.refresh(ctx, order, true) } codeURL, err := s.provider.Create(ctx, order) if err != nil { return order, err } if !validCodeURL(codeURL) { return order, ErrVerification } return s.repo.UpdatePaymentOrder(ctx, order.ID, StatusPending, codeURL) } func (s *Service) Get(ctx context.Context, organizationID, id string) (Order, error) { if s.repo == nil { return Order{}, ErrUnavailable } order, err := s.repo.GetPaymentOrder(ctx, id) if err != nil { return Order{}, err } if order.OrganizationID != organizationID { return Order{}, ErrNotFound } if order.Status != StatusPending || s.provider == nil || s.now().Sub(order.UpdatedAt) < queryInterval { return order, nil } refreshed, err := s.refresh(ctx, order, true) if err != nil { return order, nil } // Polling remains available during a provider outage. return refreshed, nil } func (s *Service) List(ctx context.Context, organizationID string) ([]Order, error) { if s.repo == nil { return nil, ErrUnavailable } return s.repo.ListPaymentOrders(ctx, organizationID, 100) } func (s *Service) Notify(ctx context.Context, request *http.Request) error { if s.repo == nil || s.provider == nil { return ErrUnavailable } txn, err := s.provider.ParseNotification(ctx, request) if err != nil { return err } _, err = s.settle(ctx, txn) return err } func (s *Service) Reconcile(ctx context.Context) error { if s.repo == nil || s.provider == nil { return ErrUnavailable } // Payment and refund queues must each get a chance within the application's // bounded reconciliation context, even when the other provider API is slow. errorsCh := make(chan error, 1) go func() { errorsCh <- s.reconcileRefunds(ctx) }() paymentErr := s.reconcilePayments(ctx) refundErr := <-errorsCh return errors.Join(paymentErr, refundErr) } func (s *Service) reconcilePayments(ctx context.Context) error { orders, err := s.repo.PendingPaymentOrders(ctx, 100) if err != nil { return err } var failures []error for _, order := range orders { if ctx.Err() != nil { return ctx.Err() } if s.now().Sub(order.UpdatedAt) < queryInterval { continue } if _, err := s.refresh(ctx, order, true); err != nil { failures = append(failures, fmt.Errorf("order %s: %w", order.ID, err)) } } return errors.Join(failures...) } func (s *Service) refresh(ctx context.Context, order Order, expire bool) (Order, error) { // Advance the oldest-pending cursor before any remote call. Failed queries, // prepay retries and close attempts must not starve newer paid orders. current, err := s.repo.UpdatePaymentOrder(ctx, order.ID, StatusPending, "") if err != nil { return order, err } if current.Status != StatusPending { return current, nil } order = current txn, err := s.provider.Query(ctx, order.ID) if err != nil && !errors.Is(err, ErrRemoteNotFound) { return order, err } if err == nil { if txn.OrderID != order.ID || txn.MchID != order.MchID || txn.AppID != order.AppID || (txn.AmountFen != 0 && txn.AmountFen != order.AmountFen) || (txn.Currency != "" && txn.Currency != order.Currency) { return order, ErrVerification } if txn.TradeState == "SUCCESS" { return s.settle(ctx, txn) } if txn.TradeState == "CLOSED" || txn.TradeState == "REVOKED" || txn.TradeState == "PAYERROR" { return s.repo.UpdatePaymentOrder(ctx, order.ID, StatusClosed, "") } if txn.TradeState != "NOTPAY" && txn.TradeState != "USERPAYING" { return order, ErrVerification } } if expire && !s.now().Before(order.ExpiresAt) { if err == nil { if err = s.provider.Close(ctx, order.ID); err != nil { return order, err } } return s.repo.UpdatePaymentOrder(ctx, order.ID, StatusClosed, "") } if order.CodeURL == "" && (errors.Is(err, ErrRemoteNotFound) || (err == nil && txn.TradeState == "NOTPAY")) { codeURL, createErr := s.provider.Create(ctx, order) if createErr != nil { return order, createErr } if !validCodeURL(codeURL) { return order, ErrVerification } return s.repo.UpdatePaymentOrder(ctx, order.ID, StatusPending, codeURL) } return order, nil } func (s *Service) settle(ctx context.Context, txn Transaction) (Order, error) { if txn.OrderID == "" || txn.TransactionID == "" || txn.TradeState != "SUCCESS" || txn.MchID != s.config.MchID || txn.AppID != s.config.AppID || txn.Currency != "CNY" || txn.AmountFen <= 0 { return Order{}, ErrVerification } order, err := s.repo.GetPaymentOrder(ctx, txn.OrderID) if err != nil { return Order{}, err } if txn.AmountFen != order.AmountFen || txn.MchID != order.MchID || txn.AppID != order.AppID || txn.Currency != order.Currency { return Order{}, ErrVerification } return s.repo.SettlePaymentOrder(ctx, txn) } func newOrderID() (string, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", err } return hex.EncodeToString(b[:]), nil } func validCodeURL(url string) bool { return strings.HasPrefix(url, "weixin://wxpay/bizpayurl?") && len(url) <= 2048 && !strings.ContainsAny(url, "\r\n") }