diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go index 78a7ba12e8..6b6f129e46 100644 --- a/backend/internal/handler/payment_webhook_handler.go +++ b/backend/internal/handler/payment_webhook_handler.go @@ -83,7 +83,12 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) notification, err := provider.VerifyNotification(c.Request.Context(), rawBody, headers) if err != nil { - slog.Error("[Payment Webhook] verify failed", "provider", providerKey, "error", err, "method", c.Request.Method, "rawBody", rawBody) + truncatedBody := rawBody + if len(truncatedBody) > 200 { + truncatedBody = truncatedBody[:200] + "...(truncated)" + } + slog.Error("[Payment Webhook] verify failed", "provider", providerKey, "error", err, "method", c.Request.Method, "bodyLen", len(rawBody)) + slog.Debug("[Payment Webhook] verify failed body", "provider", providerKey, "rawBody", truncatedBody) c.String(http.StatusBadRequest, "verify failed") return } diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index 07b9954d03..973a664084 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -21,9 +21,10 @@ import ( // EasyPay constants. const ( - easypayCodeSuccess = 1 - easypayStatusPaid = 1 - easypayHTTPTimeout = 10 * time.Second + easypayCodeSuccess = 1 + easypayStatusPaid = 1 + easypayHTTPTimeout = 10 * time.Second + maxEasypayResponseSize = 1 << 20 // 1MB ) // EasyPay implements payment.Provider for the EasyPay aggregation platform. @@ -169,7 +170,7 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st } params := make(map[string]string) for k := range values { - params[k] = fullyDecodeURL(values.Get(k)) + params[k] = decodeURLValue(values.Get(k)) } sign := params["sign"] if sign == "" { @@ -239,7 +240,7 @@ func (e *EasyPay) post(ctx context.Context, endpoint string, params map[string]s return nil, err } defer func() { _ = resp.Body.Close() }() - return io.ReadAll(resp.Body) + return io.ReadAll(io.LimitReader(resp.Body, maxEasypayResponseSize)) } func easyPaySign(params map[string]string, pkey string) string { @@ -267,14 +268,11 @@ func easyPayVerifySign(params map[string]string, pkey string, sign string) bool return hmac.Equal([]byte(easyPaySign(params, pkey)), []byte(sign)) } -// fullyDecodeURL repeatedly URL-decodes a string until stable. -// Handles double (or multi) encoding caused by upstream proxies. -func fullyDecodeURL(s string) string { - for { - decoded, err := url.QueryUnescape(s) - if err != nil || decoded == s { - return s - } - s = decoded +// decodeURLValue URL-decodes a string once. +func decodeURLValue(s string) string { + decoded, err := url.QueryUnescape(s) + if err != nil { + return s } + return decoded } diff --git a/backend/internal/payment/wire.go b/backend/internal/payment/wire.go index f3dc0abf0d..880c55456d 100644 --- a/backend/internal/payment/wire.go +++ b/backend/internal/payment/wire.go @@ -2,6 +2,7 @@ package payment import ( "encoding/hex" + "log/slog" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/config" @@ -14,7 +15,18 @@ type EncryptionKey []byte // ProvideEncryptionKey derives the payment encryption key from the TOTP encryption key in config. func ProvideEncryptionKey(cfg *config.Config) EncryptionKey { - key, _ := hex.DecodeString(cfg.Totp.EncryptionKey) + if cfg.Totp.EncryptionKey == "" { + return nil + } + key, err := hex.DecodeString(cfg.Totp.EncryptionKey) + if err != nil { + slog.Error("invalid payment encryption key", "error", err) + return nil + } + if len(key) != 32 { + slog.Error("payment encryption key must be 32 bytes", "got", len(key)) + return nil + } return EncryptionKey(key) } diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index 91e90967c1..de77c30b9e 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -343,6 +343,7 @@ type ProviderInstanceResponse struct { Enabled bool `json:"enabled"` RefundEnabled bool `json:"refund_enabled"` SortOrder int `json:"sort_order"` + PaymentMode string `json:"payment_mode"` } // ListProviderInstancesWithConfig returns provider instances with decrypted @@ -359,6 +360,7 @@ func (s *PaymentConfigService) ListProviderInstancesWithConfig(ctx context.Conte ID: int64(inst.ID), ProviderKey: inst.ProviderKey, Name: inst.Name, SupportedTypes: splitTypes(inst.SupportedTypes), Limits: inst.Limits, Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, SortOrder: inst.SortOrder, + PaymentMode: inst.PaymentMode, } resp.Config = s.decryptAndMaskConfig(inst.Config) result = append(result, resp) diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 33b6b75b70..824846213d 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -147,7 +147,7 @@ type TopUserStat struct { type PaymentService struct { providerMu sync.Mutex - providerOnce sync.Once + providersLoaded bool entClient *dbent.Client registry *payment.Registry loadBalancer payment.LoadBalancer @@ -200,7 +200,9 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest } resp, err := s.invokeProvider(ctx, order, req, cfg, payAmountStr, payAmount, plan) if err != nil { - _ = s.entClient.PaymentOrder.DeleteOneID(order.ID).Exec(ctx) + _, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID). + SetStatus(OrderStatusFailed). + Save(ctx) return nil, err } return resp, nil @@ -566,15 +568,40 @@ func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo } func (s *PaymentService) toPaid(ctx context.Context, o *dbent.PaymentOrder, tradeNo string, paid float64, pk string) error { + previousStatus := o.Status now := time.Now() grace := now.Add(-paymentGraceMinutes * time.Minute) - c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.Or(paymentorder.StatusEQ(OrderStatusPending), paymentorder.And(paymentorder.StatusEQ(OrderStatusExpired), paymentorder.UpdatedAtGTE(grace)))).SetStatus(OrderStatusPaid).SetPayAmount(paid).SetPaymentTradeNo(tradeNo).SetPaidAt(now).ClearFailedAt().ClearFailedReason().Save(ctx) + c, err := s.entClient.PaymentOrder.Update().Where( + paymentorder.IDEQ(o.ID), + paymentorder.Or( + paymentorder.StatusEQ(OrderStatusPending), + paymentorder.StatusEQ(OrderStatusCancelled), + paymentorder.And( + paymentorder.StatusEQ(OrderStatusExpired), + paymentorder.UpdatedAtGTE(grace), + ), + ), + ).SetStatus(OrderStatusPaid).SetPayAmount(paid).SetPaymentTradeNo(tradeNo).SetPaidAt(now).ClearFailedAt().ClearFailedReason().Save(ctx) if err != nil { return fmt.Errorf("update to PAID: %w", err) } if c == 0 { return s.alreadyProcessed(ctx, o) } + if previousStatus == OrderStatusCancelled || previousStatus == OrderStatusExpired { + slog.Info("order recovered from webhook payment success", + "orderID", o.ID, + "previousStatus", previousStatus, + "tradeNo", tradeNo, + "provider", pk, + ) + s.writeAuditLog(ctx, o.ID, "ORDER_RECOVERED", pk, map[string]any{ + "previous_status": previousStatus, + "tradeNo": tradeNo, + "paidAmount": paid, + "reason": "webhook payment success received after order " + previousStatus, + }) + } s.writeAuditLog(ctx, o.ID, "ORDER_PAID", pk, map[string]any{"tradeNo": tradeNo, "paidAmount": paid}) return s.executeFulfillment(ctx, o.ID) } @@ -591,6 +618,18 @@ func (s *PaymentService) alreadyProcessed(ctx context.Context, o *dbent.PaymentO return s.executeFulfillment(ctx, o.ID) case OrderStatusPaid, OrderStatusRecharging: return fmt.Errorf("order %d is being processed", o.ID) + case OrderStatusExpired: + slog.Warn("webhook payment success for expired order beyond grace period", + "orderID", o.ID, + "status", cur.Status, + "updatedAt", cur.UpdatedAt, + ) + s.writeAuditLog(ctx, o.ID, "PAYMENT_AFTER_EXPIRY", "system", map[string]any{ + "status": cur.Status, + "updatedAt": cur.UpdatedAt, + "reason": "payment arrived after expiry grace period", + }) + return nil default: return nil } @@ -1174,9 +1213,12 @@ func (s *PaymentService) AdminListOrders(ctx context.Context, userID int64, p Or // It queries all enabled PaymentProviderInstance records, decrypts their config, // creates providers via provider.CreateProvider, and registers them. func (s *PaymentService) EnsureProviders(ctx context.Context) { - s.providerOnce.Do(func() { + s.providerMu.Lock() + defer s.providerMu.Unlock() + if !s.providersLoaded { s.loadProviders(ctx) - }) + s.providersLoaded = true + } } // RefreshProviders clears and re-registers all providers from the database. @@ -1186,8 +1228,7 @@ func (s *PaymentService) RefreshProviders(ctx context.Context) { defer s.providerMu.Unlock() s.registry.Clear() s.loadProviders(ctx) - s.providerOnce = sync.Once{} // reset so next EnsureProviders is a no-op until next Refresh - s.providerOnce.Do(func() {}) // mark as done since we just loaded + s.providersLoaded = true } func (s *PaymentService) loadProviders(ctx context.Context) { diff --git a/frontend/src/components/payment/PaymentMethodSelector.vue b/frontend/src/components/payment/PaymentMethodSelector.vue index 525e12b386..5e3f801cf7 100644 --- a/frontend/src/components/payment/PaymentMethodSelector.vue +++ b/frontend/src/components/payment/PaymentMethodSelector.vue @@ -39,6 +39,7 @@