package order import ( "context" "encoding/json" "time" "github.com/hibiken/asynq" "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/pkg/payment/epay" "github.com/perfect-panel/server/pkg/payment/stripe" queueTypes "github.com/perfect-panel/server/queue/types" "gorm.io/gorm" "github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/model/payment" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/payment/alipay" ) // GatewayPaymentStatus is the tri-state result of an external payment-gateway // query. The third state (Unknown) is the whole point of HIF-137: when the // gateway is unreachable or returns an inconclusive answer we must NOT collapse // it to "unpaid" — that's exactly the bug that closes already-paid orders. type GatewayPaymentStatus int const ( // GatewayStatusUnknown — the gateway query itself failed (timeout, 5xx, // network error, decode error) or the payment method has no gateway we can // query. The caller must treat this as "do not close, retry later". GatewayStatusUnknown GatewayPaymentStatus = iota // GatewayStatusPaid — the gateway confirms the user has paid. GatewayStatusPaid // GatewayStatusUnpaid — the gateway confirms the order is unpaid / // cancelled / closed on its side. Safe to close locally. GatewayStatusUnpaid ) type CloseOrderLogic struct { logger.Logger ctx context.Context svcCtx *svc.ServiceContext // Test seams (injected via overrides on the returned struct). Production // code never touches these — NewCloseOrderLogic wires the real impls. gatewayQuery func(*order.Order) GatewayPaymentStatus enqueueActivate func(context.Context, []byte) (string, error) } // NewCloseOrderLogic Close order func NewCloseOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CloseOrderLogic { l := &CloseOrderLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx, } l.gatewayQuery = l.queryGatewayPaymentStatus l.enqueueActivate = func(c context.Context, payload []byte) (string, error) { task := asynq.NewTask(queueTypes.ForthwithActivateOrder, payload, asynq.MaxRetry(5)) info, err := svcCtx.Queue.EnqueueContext(c, task) if err != nil { return "", err } return info.ID, nil } return l } func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error { // Find order information by order number orderInfo, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo) if err != nil { l.Errorw("[CloseOrder] Find order info failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OrderNo), ) return nil } // If the order status is not 1, it means that the order has been closed or paid if orderInfo.Status != 1 { l.Infow("[CloseOrder] Order status is not 1", logger.Field("orderNo", req.OrderNo), logger.Field("status", orderInfo.Status), ) return nil } // HIF-137: before closing a pending order, ask the gateway whether it was // actually paid. Silent callback failures must not cause us to close a // paid order. Three branches: // - Paid → recover to status=2 + enqueue activation (do NOT close) // - Unpaid → fall through to the normal close flow // - Unknown → keep status=1 and let DeferCloseOrder retry on the next tick switch l.gatewayQuery(orderInfo) { case GatewayStatusPaid: return l.recoverPaidOrder(orderInfo) case GatewayStatusUnknown: l.Errorw("[CloseOrder] gateway query inconclusive — keeping order open (metric=close_gateway_error_kept_open)", logger.Field("metric", "close_gateway_error_kept_open"), logger.Field("orderNo", orderInfo.OrderNo), logger.Field("method", orderInfo.Method), logger.Field("trade_no", orderInfo.TradeNo), ) return nil } l.Infow("[CloseOrder] gateway confirmed unpaid — proceeding with normal close (metric=close_normal)", logger.Field("metric", "close_normal"), logger.Field("orderNo", orderInfo.OrderNo), logger.Field("method", orderInfo.Method), ) sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, orderInfo.SubscribeId) if err != nil { l.Errorw("[CloseOrder] Find subscribe info failed", logger.Field("error", err.Error()), logger.Field("subscribeId", orderInfo.SubscribeId), ) return nil } err = l.svcCtx.DB.Transaction(func(tx *gorm.DB) error { // update order status err := tx.Model(&order.Order{}).Where("order_no = ?", req.OrderNo).Update("status", 3).Error if err != nil { l.Errorw("[CloseOrder] Update order status failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OrderNo), ) return err } // If User ID is 0, it means that the order is a guest order and does not need to be refunded, the order can be deleted directly if orderInfo.UserId == 0 { err = tx.Model(&order.Order{}).Where("order_no = ?", req.OrderNo).Delete(&order.Order{}).Error if err != nil { l.Errorw("[CloseOrder] Delete order failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OrderNo), ) return err } return nil } // refund deduction amount to user deduction balance if orderInfo.GiftAmount > 0 { userInfo, err := l.svcCtx.UserModel.FindOne(l.ctx, orderInfo.UserId) if err != nil { l.Errorw("[CloseOrder] Find user info failed", logger.Field("error", err.Error()), logger.Field("user_id", orderInfo.UserId), ) return err } deduction := userInfo.GiftAmount + orderInfo.GiftAmount err = tx.Model(&user.User{}).Where("id = ?", orderInfo.UserId).Update("gift_amount", deduction).Error if err != nil { l.Errorw("[CloseOrder] Refund deduction amount failed", logger.Field("error", err.Error()), logger.Field("uid", orderInfo.UserId), logger.Field("deduction", orderInfo.GiftAmount), ) return err } // Record the deduction refund log giftLog := log.Gift{ Type: log.GiftTypeIncrease, OrderNo: orderInfo.OrderNo, SubscribeId: 0, Amount: orderInfo.GiftAmount, Balance: deduction, Remark: "Order cancellation refund", Timestamp: time.Now().UnixMilli(), } content, _ := giftLog.Marshal() err = tx.Model(&log.SystemLog{}).Create(&log.SystemLog{ Id: 0, Type: log.TypeGift.Uint8(), Date: time.Now().Format(time.DateOnly), ObjectID: userInfo.Id, Content: string(content), }).Error if err != nil { l.Errorw("[CloseOrder] Record cancellation refund log failed", logger.Field("error", err.Error()), logger.Field("uid", orderInfo.UserId), logger.Field("deduction", orderInfo.GiftAmount), ) return err } } // Note: user cache will be updated after transaction commits if sub.Inventory != -1 { sub.Inventory++ if e := l.svcCtx.SubscribeModel.Update(l.ctx, sub, tx); e != nil { l.Errorw("[CloseOrder] Restore subscribe inventory failed", logger.Field("error", e.Error()), logger.Field("subscribeId", sub.Id), ) return e } } return nil }) if err != nil { logger.Errorf("[CloseOrder] Transaction failed: %v", err.Error()) return err } // Update user cache after transaction commits successfully if orderInfo.GiftAmount > 0 && orderInfo.UserId != 0 { if userInfo, findErr := l.svcCtx.UserModel.FindOne(l.ctx, orderInfo.UserId); findErr == nil { if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userInfo); clearErr != nil { l.Errorw("[CloseOrder] failed to clear user cache", logger.Field("error", clearErr.Error()), logger.Field("user_id", orderInfo.UserId), ) } } } return nil } // recoverPaidOrder is the "gateway said paid" branch: flip the order to // status=2 (paid), persist the trade_no if we have one, and enqueue the same // activation task the notify handlers would have enqueued. We deliberately // reuse ForthwithActivateOrder so the rest of the activation pipeline // (idempotency, claim/release, commission, etc.) stays untouched. func (l *CloseOrderLogic) recoverPaidOrder(orderInfo *order.Order) error { updates := map[string]any{"status": 2} if orderInfo.TradeNo != "" { updates["trade_no"] = orderInfo.TradeNo } // Race-safe: only flip 1→2. If another worker already moved the order // forward (e.g. a late notify finally landed), do nothing. result := l.svcCtx.DB.WithContext(l.ctx). Model(&order.Order{}). Where("order_no = ? AND status = ?", orderInfo.OrderNo, 1). Updates(updates) if result.Error != nil { l.Errorw("[CloseOrder] gateway-paid recovery update failed — keeping order open", logger.Field("metric", "close_gateway_error_kept_open"), logger.Field("error", result.Error.Error()), logger.Field("orderNo", orderInfo.OrderNo), ) return nil } if result.RowsAffected == 0 { l.Infow("[CloseOrder] gateway-paid recovery skipped — order already moved past status=1", logger.Field("orderNo", orderInfo.OrderNo), ) return nil } payload := queueTypes.ForthwithActivateOrderPayload{OrderNo: orderInfo.OrderNo} bytes, err := json.Marshal(&payload) if err != nil { l.Errorw("[CloseOrder] marshal activation payload failed", logger.Field("error", err.Error()), logger.Field("orderNo", orderInfo.OrderNo), ) return nil } taskID, err := l.enqueueActivate(l.ctx, bytes) if err != nil { // Order is already at status=2; if enqueue fails the stuck-order // recovery sweeper will pick it up. Log loudly but don't error out. l.Errorw("[CloseOrder] enqueue activation task failed after gateway-paid recovery", logger.Field("error", err.Error()), logger.Field("orderNo", orderInfo.OrderNo), ) return nil } l.Infow("[CloseOrder] gateway-paid recovery succeeded — order promoted to paid + activation enqueued (metric=close_with_gateway_paid_recovered)", logger.Field("metric", "close_with_gateway_paid_recovered"), logger.Field("orderNo", orderInfo.OrderNo), logger.Field("method", orderInfo.Method), logger.Field("trade_no", orderInfo.TradeNo), logger.Field("queue_task_id", taskID), ) return nil } // queryGatewayPaymentStatus dispatches to the right gateway client for the // order's payment method and returns a tri-state result. Methods without an // external gateway (Balance, unknown) return Unpaid — the historical close // path is preserved for them. func (l *CloseOrderLogic) queryGatewayPaymentStatus(orderInfo *order.Order) GatewayPaymentStatus { switch orderInfo.Method { case AlipayF2f: return l.queryAlipay(orderInfo) case StripeAlipay, StripeWeChatPay: return l.queryStripe(orderInfo) case Epay: return l.queryEpay(orderInfo) case Balance: // Balance is settled in-process; no external gateway to ask. If status=1 // here, the balance deduction simply never completed — safe to close. return GatewayStatusUnpaid default: l.Infow("[CloseOrder] no gateway query for method — falling back to close", logger.Field("method", orderInfo.Method), logger.Field("orderNo", orderInfo.OrderNo), ) return GatewayStatusUnpaid } } // queryAlipay queries Alipay F2F and maps the response to a tri-state. // "trade not exist" on Alipay's side is treated as Unpaid (the user never // completed the QR-code scan), not Unknown. func (l *CloseOrderLogic) queryAlipay(orderInfo *order.Order) GatewayPaymentStatus { if orderInfo.TradeNo == "" { // Alipay F2F creates the trade lazily — no trade_no means the user // never scanned. Treat as definitively unpaid. return GatewayStatusUnpaid } paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId) if err != nil { l.Errorw("[CloseOrder] Find payment config failed", logger.Field("error", err.Error()), logger.Field("paymentMark", orderInfo.Method), ) return GatewayStatusUnknown } config := payment.AlipayF2FConfig{} if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil { l.Errorw("[CloseOrder] Unmarshal Alipay config failed", logger.Field("error", err.Error()), logger.Field("orderNo", orderInfo.OrderNo), ) return GatewayStatusUnknown } client := alipay.NewClient(alipay.Config{ AppId: config.AppId, PrivateKey: config.PrivateKey, PublicKey: config.PublicKey, InvoiceName: config.InvoiceName, }) if client == nil { return GatewayStatusUnknown } status, err := client.QueryTrade(l.ctx, orderInfo.TradeNo) if err != nil { l.Errorw("[CloseOrder] Alipay QueryTrade failed", logger.Field("error", err.Error()), logger.Field("orderNo", orderInfo.OrderNo), logger.Field("tradeNo", orderInfo.TradeNo), ) return GatewayStatusUnknown } switch status { case alipay.Success, alipay.Finished: return GatewayStatusPaid case alipay.Pending, alipay.Closed: return GatewayStatusUnpaid default: // Unknown alipay status — be conservative. return GatewayStatusUnknown } } // queryStripe queries Stripe PaymentIntent and maps to a tri-state. // Any Stripe-side error (network, 5xx, decode) is Unknown — Stripe is the // gateway most prone to silent webhook drops in this codebase, so we must not // downgrade query failures to "unpaid". func (l *CloseOrderLogic) queryStripe(orderInfo *order.Order) GatewayPaymentStatus { if orderInfo.TradeNo == "" { // Stripe's PaymentIntent ID is written into trade_no at create time. // Missing it means the intent was never persisted — treat as unpaid. return GatewayStatusUnpaid } paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId) if err != nil { l.Errorw("[CloseOrder] Find payment config failed", logger.Field("error", err.Error()), logger.Field("paymentMark", orderInfo.Method), ) return GatewayStatusUnknown } config := payment.StripeConfig{} if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil { l.Errorw("[CloseOrder] Unmarshal Stripe config failed", logger.Field("error", err.Error()), logger.Field("orderNo", orderInfo.OrderNo), ) return GatewayStatusUnknown } client := stripe.NewClient(stripe.Config{ PublicKey: config.PublicKey, SecretKey: config.SecretKey, WebhookSecret: config.WebhookSecret, }) paid, err := client.QueryOrderStatus(orderInfo.TradeNo) if err != nil { l.Errorw("[CloseOrder] Stripe QueryOrderStatus failed", logger.Field("error", err.Error()), logger.Field("orderNo", orderInfo.OrderNo), logger.Field("tradeNo", orderInfo.TradeNo), ) return GatewayStatusUnknown } if paid { return GatewayStatusPaid } return GatewayStatusUnpaid } // queryEpay queries EPay using out_trade_no (EPay's order endpoint accepts // out_trade_no even when we never recorded its internal trade_no, which is the // common case for orders that lost their notify callback). func (l *CloseOrderLogic) queryEpay(orderInfo *order.Order) GatewayPaymentStatus { paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId) if err != nil { l.Errorw("[CloseOrder] Find payment config failed", logger.Field("error", err.Error()), logger.Field("paymentMark", orderInfo.Method), ) return GatewayStatusUnknown } config := payment.EPayConfig{} if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil { l.Errorw("[CloseOrder] Unmarshal EPay config failed", logger.Field("error", err.Error()), logger.Field("orderNo", orderInfo.OrderNo), ) return GatewayStatusUnknown } if config.Url == "" || config.Pid == "" || config.Key == "" { l.Errorw("[CloseOrder] EPay config incomplete", logger.Field("orderNo", orderInfo.OrderNo), ) return GatewayStatusUnknown } client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type) paid, err := client.QueryOrderStatus(orderInfo.OrderNo) if err != nil { l.Errorw("[CloseOrder] EPay QueryOrderStatus failed", logger.Field("error", err.Error()), logger.Field("orderNo", orderInfo.OrderNo), ) return GatewayStatusUnknown } if paid { return GatewayStatusPaid } return GatewayStatusUnpaid }