mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-22 07:30:51 +00:00
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:
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user