From 576c34bbf70209321553480ee5483b76b764fc1f Mon Sep 17 00:00:00 2001 From: erio Date: Sat, 11 Apr 2026 15:46:28 +0800 Subject: [PATCH] fix(payment): add active upstream sync to polling and fix Stripe countdown - Add POST /payment/orders/:id/sync endpoint that queries upstream provider on each poll, complementing webhooks for timely payment detection when webhooks are delayed or unreachable - Fix Stripe payment countdown: pass expires_at to PaymentStatusPanel instead of empty string (was falling back to hardcoded 30 minutes) - Idempotent: toPaid uses atomic UPDATE WHERE status=PENDING, so concurrent webhook + sync calls won't double-credit --- backend/internal/handler/payment_handler.go | 25 +++++++++++++++++++ backend/internal/server/routes/payment.go | 1 + .../service/payment_order_lifecycle.go | 25 +++++++++++++++++++ frontend/src/api/payment.ts | 5 ++++ .../components/payment/PaymentQRDialog.vue | 2 +- .../components/payment/PaymentStatusPanel.vue | 2 +- frontend/src/stores/payment.ts | 18 ++++++++++++- frontend/src/views/user/PaymentQRCodeView.vue | 2 +- frontend/src/views/user/PaymentView.vue | 2 +- frontend/src/views/user/StripePaymentView.vue | 2 +- frontend/src/views/user/StripePopupView.vue | 3 ++- 11 files changed, 80 insertions(+), 7 deletions(-) diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 0425fc49c7..d9f09c02c7 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -281,6 +281,31 @@ func (h *PaymentHandler) GetOrder(c *gin.Context) { response.Success(c, order) } +// SyncOrder queries the upstream payment provider to check if a pending +// order has been paid. This complements webhooks — the frontend calls it +// during active polling so that payment is detected even when the webhook +// is delayed or unreachable. +// POST /api/v1/payment/orders/:id/sync +func (h *PaymentHandler) SyncOrder(c *gin.Context) { + subject, ok := requireAuth(c) + if !ok { + return + } + + orderID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid order ID") + return + } + + order, err := h.paymentService.SyncOrderStatus(c.Request.Context(), orderID, subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, order) +} + // CancelOrder cancels a pending order for the authenticated user. // POST /api/v1/payment/orders/:id/cancel func (h *PaymentHandler) CancelOrder(c *gin.Context) { diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index 6bf04679d4..f46d0c1fec 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -37,6 +37,7 @@ func RegisterPaymentRoutes( orders.POST("/verify", paymentHandler.VerifyOrder) orders.GET("/my", paymentHandler.GetMyOrders) orders.GET("/:id", paymentHandler.GetOrder) + orders.POST("/:id/sync", paymentHandler.SyncOrder) orders.POST("/:id/cancel", paymentHandler.CancelOrder) orders.POST("/:id/refund-request", paymentHandler.RequestRefund) } diff --git a/backend/internal/service/payment_order_lifecycle.go b/backend/internal/service/payment_order_lifecycle.go index 801471804c..b2de05d449 100644 --- a/backend/internal/service/payment_order_lifecycle.go +++ b/backend/internal/service/payment_order_lifecycle.go @@ -163,6 +163,31 @@ func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) s return "" } +// SyncOrderStatus queries the upstream provider to check if a PENDING order +// has been paid. Called by frontend polling to complement webhooks — if the +// webhook is delayed or unreachable, this ensures timely payment detection. +// Idempotent: toPaid uses atomic UPDATE WHERE status=PENDING, so concurrent +// calls from webhook + sync won't double-credit. +func (s *PaymentService) SyncOrderStatus(ctx context.Context, orderID int64, userID int64) (*dbent.PaymentOrder, error) { + o, err := s.entClient.PaymentOrder.Get(ctx, orderID) + if err != nil { + return nil, infraerrors.NotFound("NOT_FOUND", "order not found") + } + if o.UserID != userID { + return nil, infraerrors.Forbidden("FORBIDDEN", "no permission for this order") + } + if o.Status == OrderStatusPending { + result := s.checkPaid(ctx, o) + if result == checkPaidResultAlreadyPaid { + o, err = s.entClient.PaymentOrder.Get(ctx, orderID) + if err != nil { + return nil, fmt.Errorf("reload order: %w", err) + } + } + } + return o, nil +} + // VerifyOrderByOutTradeNo actively queries the upstream provider to check // if a payment was made, and processes it if so. This handles the case where // the provider's notify callback was missed (e.g. EasyPay popup mode). diff --git a/frontend/src/api/payment.ts b/frontend/src/api/payment.ts index 1389b60f88..1d4a303dd7 100644 --- a/frontend/src/api/payment.ts +++ b/frontend/src/api/payment.ts @@ -57,6 +57,11 @@ export const paymentAPI = { return apiClient.get(`/payment/orders/${id}`) }, + /** Sync order status with upstream provider (complements webhooks) */ + syncOrder(id: number) { + return apiClient.post(`/payment/orders/${id}/sync`) + }, + /** Cancel a pending order */ cancelOrder(id: number) { return apiClient.post(`/payment/orders/${id}/cancel`) diff --git a/frontend/src/components/payment/PaymentQRDialog.vue b/frontend/src/components/payment/PaymentQRDialog.vue index 670c1cc5cc..2b6121f09d 100644 --- a/frontend/src/components/payment/PaymentQRDialog.vue +++ b/frontend/src/components/payment/PaymentQRDialog.vue @@ -182,7 +182,7 @@ async function renderQR() { async function pollStatus() { if (!props.orderId) return - const order = await paymentStore.pollOrderStatus(props.orderId) + const order = await paymentStore.syncOrderStatus(props.orderId) if (!order) return if (order.status === 'COMPLETED' || order.status === 'PAID') { cleanup() diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index 087b3dcf5e..0a894fca19 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -205,7 +205,7 @@ async function renderQR() { async function pollStatus() { if (!props.orderId || outcome.value) return - const order = await paymentStore.pollOrderStatus(props.orderId) + const order = await paymentStore.syncOrderStatus(props.orderId) if (!order) return if (order.status === 'COMPLETED' || order.status === 'PAID') { cleanup() diff --git a/frontend/src/stores/payment.ts b/frontend/src/stores/payment.ts index ce2d7b3a1e..9652bca0be 100644 --- a/frontend/src/stores/payment.ts +++ b/frontend/src/stores/payment.ts @@ -66,7 +66,7 @@ export const usePaymentStore = defineStore('payment', () => { return response.data } - /** Poll order status by ID */ + /** Poll order status by ID (read-only, no upstream check) */ async function pollOrderStatus(orderId: number): Promise { try { const response = await paymentAPI.getOrder(orderId) @@ -81,6 +81,21 @@ export const usePaymentStore = defineStore('payment', () => { } } + /** Sync order status with upstream provider (checks if paid) */ + async function syncOrderStatus(orderId: number): Promise { + try { + const response = await paymentAPI.syncOrder(orderId) + const order = response.data + if (currentOrder.value?.id === orderId) { + currentOrder.value = order + } + return order + } catch (error: unknown) { + console.error('[payment] Failed to sync order status:', error) + return null + } + } + /** Clear current order state */ function clearCurrentOrder() { currentOrder.value = null @@ -96,6 +111,7 @@ export const usePaymentStore = defineStore('payment', () => { fetchPlans, createOrder, pollOrderStatus, + syncOrderStatus, clearCurrentOrder } }) diff --git a/frontend/src/views/user/PaymentQRCodeView.vue b/frontend/src/views/user/PaymentQRCodeView.vue index 50173a1a8d..52675d77ec 100644 --- a/frontend/src/views/user/PaymentQRCodeView.vue +++ b/frontend/src/views/user/PaymentQRCodeView.vue @@ -133,7 +133,7 @@ async function renderQR() { async function pollStatus() { if (!orderId.value) return - const order = await paymentStore.pollOrderStatus(orderId.value) + const order = await paymentStore.syncOrderStatus(orderId.value) if (!order) return if (order.status === 'COMPLETED' || order.status === 'PAID') { cleanup() diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 411798dbaa..5a95809759 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -537,7 +537,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb if (result.client_secret) { // Stripe: show Payment Element inline (user picks method → confirms → redirect if needed) paymentState.value = { - orderId: result.order_id, qrCode: '', expiresAt: '', + orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '', clientSecret: result.client_secret, payAmount: result.pay_amount, orderType, diff --git a/frontend/src/views/user/StripePaymentView.vue b/frontend/src/views/user/StripePaymentView.vue index 20a4a40837..6575d7bf75 100644 --- a/frontend/src/views/user/StripePaymentView.vue +++ b/frontend/src/views/user/StripePaymentView.vue @@ -260,7 +260,7 @@ function startPolling() { const orderId = Number(route.query.order_id) if (!orderId) return pollTimer = setInterval(async () => { - const o = await paymentStore.pollOrderStatus(orderId) + const o = await paymentStore.syncOrderStatus(orderId) if (!o) return if (o.status === 'COMPLETED' || o.status === 'PAID') { if (pollTimer) { clearInterval(pollTimer); pollTimer = null } diff --git a/frontend/src/views/user/StripePopupView.vue b/frontend/src/views/user/StripePopupView.vue index 2704c62db1..0254829d82 100644 --- a/frontend/src/views/user/StripePopupView.vue +++ b/frontend/src/views/user/StripePopupView.vue @@ -152,7 +152,8 @@ function startPolling() { try { const token = document.cookie.split('; ').find(c => c.startsWith('token='))?.split('=')[1] || localStorage.getItem('token') || '' - const res = await fetch('/api/v1/payment/orders/' + orderId, { + const res = await fetch('/api/v1/payment/orders/' + orderId + '/sync', { + method: 'POST', headers: token ? { Authorization: 'Bearer ' + token } : {}, credentials: 'include', })