diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go index 34aebf9d9b..db472a721e 100644 --- a/backend/internal/handler/payment_webhook_handler.go +++ b/backend/internal/handler/payment_webhook_handler.go @@ -55,11 +55,18 @@ func (h *PaymentWebhookHandler) StripeWebhook(c *gin.Context) { // handleNotify is the shared logic for all provider webhook handlers. func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) { - body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxWebhookBodySize)) - if err != nil { - slog.Error("[Payment Webhook] failed to read body", "provider", providerKey, "error", err) - c.String(http.StatusBadRequest, "failed to read body") - return + var rawBody string + if c.Request.Method == http.MethodGet { + // GET callbacks (e.g. EasyPay) pass params as URL query string + rawBody = c.Request.URL.RawQuery + } else { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxWebhookBodySize)) + if err != nil { + slog.Error("[Payment Webhook] failed to read body", "provider", providerKey, "error", err) + c.String(http.StatusBadRequest, "failed to read body") + return + } + rawBody = string(body) } provider, err := h.registry.GetProviderByKey(providerKey) @@ -74,7 +81,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) headers[strings.ToLower(k)] = c.GetHeader(k) } - notification, err := provider.VerifyNotification(c.Request.Context(), string(body), headers) + notification, err := provider.VerifyNotification(c.Request.Context(), rawBody, headers) if err != nil { slog.Error("[Payment Webhook] verify failed", "provider", providerKey, "error", err) c.String(http.StatusBadRequest, "verify failed") diff --git a/backend/internal/payment/load_balancer.go b/backend/internal/payment/load_balancer.go index 4a53d3d5ee..68d6bd8e28 100644 --- a/backend/internal/payment/load_balancer.go +++ b/backend/internal/payment/load_balancer.go @@ -82,8 +82,9 @@ func (lb *DefaultLoadBalancer) SelectInstance(ctx context.Context, providerKey s } return &InstanceSelection{ - InstanceID: fmt.Sprintf("%d", selected.ID), - Config: config, + InstanceID: fmt.Sprintf("%d", selected.ID), + Config: config, + SupportedTypes: selected.SupportedTypes, }, nil } diff --git a/backend/internal/payment/provider/stripe.go b/backend/internal/payment/provider/stripe.go index 2d499c6c58..f9604ccac5 100644 --- a/backend/internal/payment/provider/stripe.go +++ b/backend/internal/payment/provider/stripe.go @@ -6,6 +6,7 @@ import ( "fmt" "math" "strconv" + "strings" "sync" "github.com/Wei-Shaw/sub2api/internal/payment" @@ -59,7 +60,7 @@ func (s *Stripe) GetPublishableKey() string { func (s *Stripe) Name() string { return "Stripe" } func (s *Stripe) ProviderKey() string { return "stripe" } func (s *Stripe) SupportedTypes() []payment.PaymentType { - return []payment.PaymentType{payment.TypeCard, payment.TypeAlipay, payment.TypeWxpay, payment.TypeLink} + return []payment.PaymentType{payment.TypeStripe} } // centsToYuan converts an amount in cents (int64) to yuan (float64). @@ -93,10 +94,8 @@ func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentReq return nil, fmt.Errorf("stripe create payment: %w", err) } - methods, ok := stripePaymentMethodTypes[req.PaymentType] - if !ok { - methods = []string{"card"} - } + // Collect all Stripe payment_method_types from the instance's configured sub-methods + methods := resolveStripeMethodTypes(req.InstanceSubMethods) pmTypes := make([]*string, len(methods)) for i, m := range methods { @@ -112,7 +111,7 @@ func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentReq } // WeChat Pay requires payment_method_options with client type - if req.PaymentType == payment.TypeWxpay { + if hasStripeMethod(methods, "wechat_pay") { params.PaymentMethodOptions = &stripe.PaymentIntentCreatePaymentMethodOptionsParams{ WeChatPay: &stripe.PaymentIntentCreatePaymentMethodOptionsWeChatPayParams{ Client: stripe.String("web"), @@ -233,6 +232,35 @@ func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*paymen }, nil } +// resolveStripeMethodTypes converts instance supported_types (comma-separated) +// into Stripe API payment_method_types. Falls back to ["card"] if empty. +func resolveStripeMethodTypes(instanceSubMethods string) []string { + if instanceSubMethods == "" { + return []string{"card"} + } + var methods []string + for _, t := range strings.Split(instanceSubMethods, ",") { + t = strings.TrimSpace(t) + if mapped, ok := stripePaymentMethodTypes[t]; ok { + methods = append(methods, mapped...) + } + } + if len(methods) == 0 { + return []string{"card"} + } + return methods +} + +// hasStripeMethod checks if the given Stripe method list contains the target method. +func hasStripeMethod(methods []string, target string) bool { + for _, m := range methods { + if m == target { + return true + } + } + return false +} + // CancelPayment cancels a pending PaymentIntent. func (s *Stripe) CancelPayment(ctx context.Context, tradeNo string) error { s.ensureInit() diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index 7596af0608..80849db5a0 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -41,16 +41,12 @@ func GetBasePaymentType(t string) string { switch { case t == "easypay": return "easypay" - case t == "card": - return "card" - case t == "link": - return "link" + case t == "stripe" || t == "card" || t == "link": + return "stripe" case len(t) >= 6 && t[:6] == "alipay": return "alipay" case len(t) >= 5 && t[:5] == "wxpay": return "wxpay" - case len(t) >= 6 && t[:6] == "stripe": - return "stripe" default: return t } @@ -58,14 +54,15 @@ func GetBasePaymentType(t string) string { // CreatePaymentRequest holds the parameters for creating a new payment. type CreatePaymentRequest struct { - OrderID string // Internal order ID - Amount string // Pay amount in CNY (formatted to 2 decimal places) - PaymentType string // e.g. "alipay", "wxpay" - Subject string // Product description - NotifyURL string // Webhook callback URL - ReturnURL string // Browser redirect URL after payment - ClientIP string // Payer's IP address - IsMobile bool // Whether the request comes from a mobile device + OrderID string // Internal order ID + Amount string // Pay amount in CNY (formatted to 2 decimal places) + PaymentType string // e.g. "alipay", "wxpay", "stripe" + Subject string // Product description + NotifyURL string // Webhook callback URL + ReturnURL string // Browser redirect URL after payment + ClientIP string // Payer's IP address + IsMobile bool // Whether the request comes from a mobile device + InstanceSubMethods string // Comma-separated sub-methods from instance supported_types (for Stripe) } // CreatePaymentResponse is returned after successfully initiating a payment. @@ -109,8 +106,9 @@ type RefundResponse struct { // InstanceSelection holds the selected provider instance and its decrypted config. type InstanceSelection struct { - InstanceID string - Config map[string]string + InstanceID string + Config map[string]string + SupportedTypes string // Comma-separated list of supported payment types from the instance } // Provider defines the interface that all payment providers must implement. diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index f6e94fefd7..7f734fd2a2 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -43,6 +43,8 @@ func RegisterPaymentRoutes( // --- Webhook endpoints (no auth) --- webhook := v1.Group("/payment/webhook") { + // EasyPay sends GET callbacks with query params + webhook.GET("/easypay", webhookHandler.EasyPayNotify) webhook.POST("/easypay", webhookHandler.EasyPayNotify) webhook.POST("/alipay", webhookHandler.AlipayNotify) webhook.POST("/wxpay", webhookHandler.WxpayNotify) diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index 19a1e8a62a..d4006d49f9 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -627,9 +627,13 @@ func (s *PaymentConfigService) GetPlan(ctx context.Context, id int64) (*dbent.Su return plan, nil } +// stripeSubTypes are types that should be aggregated under "stripe" for user-facing display. +var stripeSubTypes = map[string]bool{"card": true, "link": true} + // GetAvailableMethodLimits collects all payment types from enabled provider // instances and returns limits for each. This is used by the user-facing payment // page to discover which payment methods are actually available. +// Stripe sub-types (card, link) are aggregated under "stripe". func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) ([]MethodLimits, error) { instances, err := s.entClient.PaymentProviderInstance.Query(). Where(paymentproviderinstance.EnabledEQ(true)).All(ctx) @@ -640,7 +644,12 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) ([] typeSet := make(map[string]bool) for _, inst := range instances { for _, t := range splitTypes(inst.SupportedTypes) { - typeSet[t] = true + // Stripe sub-types (card, link) are represented as "stripe" to users + if stripeSubTypes[t] || t == "stripe" { + typeSet["stripe"] = true + } else { + typeSet[t] = true + } } } result := make([]MethodLimits, 0, len(typeSet)) @@ -648,6 +657,10 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) ([] ml := MethodLimits{PaymentType: pt} for _, inst := range instances { if !payment.InstanceSupportsType(inst.SupportedTypes, pt) { + // For "stripe", check if instance supports any stripe sub-type + if pt == "stripe" && inst.ProviderKey == "stripe" { + pcApplyInstanceLimits(inst, pt, &ml) + } continue } pcApplyInstanceLimits(inst, pt, &ml) diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index e0fb897aa3..07f39b7962 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -336,7 +336,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no available payment instance") } subject := s.buildPaymentSubject(plan, payAmountStr, cfg) - pr, err := provider.CreatePayment(ctx, payment.CreatePaymentRequest{OrderID: formatOrderID(order.ID), Amount: payAmountStr, PaymentType: req.PaymentType, Subject: subject, ClientIP: req.ClientIP, IsMobile: req.IsMobile}) + pr, err := provider.CreatePayment(ctx, payment.CreatePaymentRequest{OrderID: formatOrderID(order.ID), Amount: payAmountStr, PaymentType: req.PaymentType, Subject: subject, ClientIP: req.ClientIP, IsMobile: req.IsMobile, InstanceSubMethods: sel.SupportedTypes}) if err != nil { return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable") } diff --git a/frontend/src/components/payment/PaymentMethodSelector.vue b/frontend/src/components/payment/PaymentMethodSelector.vue index 69ee9413a8..c315c5a068 100644 --- a/frontend/src/components/payment/PaymentMethodSelector.vue +++ b/frontend/src/components/payment/PaymentMethodSelector.vue @@ -63,15 +63,14 @@ const emit = defineEmits<{ const { t } = useI18n() function isStripe(type: string) { - return type === 'stripe' || type === 'card' || type === 'link' + return type === 'stripe' } function iconBgClass(type: string): string { if (type === 'easypay') return 'bg-[#FF6B35]' if (type.includes('alipay')) return 'bg-[#00AEEF]' if (type.includes('wxpay')) return 'bg-[#07C160]' - if (type === 'stripe' || type === 'card') return 'bg-[#635bff]' - if (type === 'link') return 'bg-[#00D66F]' + if (type === 'stripe') return 'bg-[#635bff]' return 'bg-gray-500' } @@ -86,8 +85,7 @@ function methodSelectedClass(type: string): string { if (type === 'easypay') return 'border-[#FF6B35] bg-orange-50 text-gray-900 shadow-sm dark:bg-orange-950 dark:text-gray-100' if (type.includes('alipay')) return 'border-[#00AEEF] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100' if (type.includes('wxpay')) return 'border-[#07C160] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100' - if (type === 'stripe' || type === 'card') return 'border-[#635bff] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100' - if (type === 'link') return 'border-[#00D66F] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100' + if (type === 'stripe') return 'border-[#635bff] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100' return 'border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100' } diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index a7fa9eaae1..c073b5ad62 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -18,7 +18,7 @@ export type OrderStatus = | 'REFUNDED' | 'REFUND_FAILED' -export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'card' | 'link' +export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'easypay' export type OrderType = 'balance' | 'subscription' diff --git a/frontend/src/views/user/PaymentQRCodeView.vue b/frontend/src/views/user/PaymentQRCodeView.vue index b9fa1ff212..d5868884fa 100644 --- a/frontend/src/views/user/PaymentQRCodeView.vue +++ b/frontend/src/views/user/PaymentQRCodeView.vue @@ -7,13 +7,17 @@

{{ t('payment.qr.expired') }}

- +

{{ t('payment.qr.expiresIn') }}

{{ countdownDisplay }}

{{ t('payment.qr.waitingPayment') }}

+ + @@ -24,18 +28,23 @@ import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import AppLayout from '@/components/layout/AppLayout.vue' import { usePaymentStore } from '@/stores/payment' +import { paymentAPI } from '@/api/payment' +import { extractApiErrorMessage } from '@/utils/apiError' +import { useAppStore } from '@/stores' import QRCode from 'qrcode' const { t } = useI18n() const route = useRoute() const router = useRouter() const paymentStore = usePaymentStore() +const appStore = useAppStore() const qrCanvas = ref(null) const qrUrl = ref('') const orderId = ref(0) const remainingSeconds = ref(0) const expired = ref(false) +const cancelling = ref(false) let pollTimer: ReturnType | null = null let countdownTimer: ReturnType | null = null @@ -67,7 +76,11 @@ async function pollStatus() { } function startCountdown(seconds: number) { - remainingSeconds.value = seconds + remainingSeconds.value = Math.max(0, seconds) + if (remainingSeconds.value <= 0) { + expired.value = true + return + } countdownTimer = setInterval(() => { remainingSeconds.value-- if (remainingSeconds.value <= 0) { @@ -77,6 +90,20 @@ function startCountdown(seconds: number) { }, 1000) } +async function handleCancel() { + if (!orderId.value || cancelling.value) return + cancelling.value = true + try { + await paymentAPI.cancelOrder(orderId.value) + cleanup() + router.push('/purchase') + } catch (err: unknown) { + appStore.showError(extractApiErrorMessage(err, t('common.error'))) + } finally { + cancelling.value = false + } +} + function cleanup() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null } if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null } @@ -87,7 +114,16 @@ watch(qrUrl, () => renderQR()) onMounted(() => { orderId.value = Number(route.query.order_id) || 0 qrUrl.value = (route.query.qr as string) || '' - startCountdown(30 * 60) + + // Calculate countdown from expiresAt + const expiresAtStr = route.query.expires_at as string + let seconds = 30 * 60 // fallback: 30 minutes + if (expiresAtStr) { + const expiresAt = new Date(expiresAtStr) + const now = new Date() + seconds = Math.floor((expiresAt.getTime() - now.getTime()) / 1000) + } + startCountdown(seconds) pollTimer = setInterval(pollStatus, 3000) renderQR() }) diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 73aa82e055..9d1d38d33f 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -233,7 +233,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb if (result.clientSecret) { router.push({ path: '/payment/stripe', query: { order_id: String(result.orderId), client_secret: result.clientSecret } }) } else if (result.qrCode) { - router.push({ path: '/payment/qrcode', query: { order_id: String(result.orderId), qr: result.qrCode || '', pay_url: result.payUrl || '' } }) + router.push({ path: '/payment/qrcode', query: { order_id: String(result.orderId), qr: result.qrCode || '', pay_url: result.payUrl || '', expires_at: result.expiresAt || '' } }) } else if (result.payUrl) { window.location.href = result.payUrl } else {