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
This commit is contained in:
erio
2026-04-11 15:46:28 +08:00
parent 75deeb6e17
commit 576c34bbf7
11 changed files with 80 additions and 7 deletions
@@ -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) {
@@ -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)
}
@@ -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).
+5
View File
@@ -57,6 +57,11 @@ export const paymentAPI = {
return apiClient.get<PaymentOrder>(`/payment/orders/${id}`)
},
/** Sync order status with upstream provider (complements webhooks) */
syncOrder(id: number) {
return apiClient.post<PaymentOrder>(`/payment/orders/${id}/sync`)
},
/** Cancel a pending order */
cancelOrder(id: number) {
return apiClient.post(`/payment/orders/${id}/cancel`)
@@ -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()
@@ -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()
+17 -1
View File
@@ -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<PaymentOrder | null> {
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<PaymentOrder | null> {
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
}
})
@@ -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()
+1 -1
View File
@@ -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,
@@ -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 }
+2 -1
View File
@@ -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',
})