package payment import ( "context" "encoding/json" "fmt" "log" "time" "gorm.io/datatypes" "gorm.io/gorm" "hfb_sys/backend/internal/model" ) func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (*RefundDTO, error) { var originalPayment model.PaymentOrder if err := r.db.WithContext(ctx).Where("order_id = ? AND status = 'paid' AND biz_type = 'order_pay'", orderID).Order("id DESC").First(&originalPayment).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrPaymentNotFound } return nil, err } runtimeConfig, err := r.runtimeConfigForPayment(ctx, &originalPayment) if err != nil { return nil, ErrPaymentUnavailable } var existingRefund model.PaymentOrder err = r.db.WithContext(ctx).Where("order_id = ? AND biz_type = ? AND status NOT IN ('failed')", orderID, bizType).Order("id DESC").First(&existingRefund).Error if err == nil { dto := toRefundDTO(existingRefund) return &dto, nil } if err != gorm.ErrRecordNotFound { return nil, err } paymentNo, err := newPaymentNo() if err != nil { return nil, err } merchantRefundID := "REF" + paymentNo[3:] refundOrder := model.PaymentOrder{ PaymentNo: paymentNo, OrderID: orderID, OrderNo: originalPayment.OrderNo, UserID: originalPayment.UserID, Provider: runtimeConfig.Provider, MerchantID: runtimeConfig.MerchantID, ThirdOrderID: merchantRefundID, ProviderOrderID: "", PayWay: originalPayment.PayWay, JSPayFlag: originalPayment.JSPayFlag, AmountCent: refundAmountCent, BizType: bizType, Status: "refunding", } if runtimeConfig.isMockMode() { refundOrder.ProviderOrderID = "MOCKREF" + merchantRefundID refundOrder.Status = "refunded" now := time.Now() refundOrder.PaidAt = &now if remark != "" { refundOrder.RawResponse = datatypes.JSON([]byte(fmt.Sprintf(`{"mock":"true","remark":"%s"}`, remark))) } if err := r.db.WithContext(ctx).Create(&refundOrder).Error; err != nil { return nil, err } r.recordConfigUsage(ctx, runtimeConfig, &refundOrder) if err := r.updateOrderRefundStatus(ctx, orderID, refundAmountCent); err != nil { log.Printf("[payment] mock update order refund status failed order_id=%d err=%v", orderID, err) } dto := toRefundDTO(refundOrder) return &dto, nil } if err := r.db.WithContext(ctx).Create(&refundOrder).Error; err != nil { return nil, err } log.Printf("[payment] refund start order_id=%d order_no=%s payment_id=%d biz_type=%s provider=%s amount_cent=%d merchant_refund_id=%s origin_third_order_id=%s origin_provider_order_id=%s", orderID, originalPayment.OrderNo, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, merchantRefundID, originalPayment.ThirdOrderID, refundOriginProviderOrderID(originalPayment)) r.recordConfigUsage(ctx, runtimeConfig, &refundOrder) if err := r.markOrderRefunding(ctx, orderID, refundAmountCent); err != nil { log.Printf("[payment] mark order refunding failed order_id=%d err=%v", orderID, err) } if runtimeConfig.Channel == nil { _ = r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": "payment channel unavailable"}) return nil, ErrPaymentUnavailable } resp, err := runtimeConfig.Channel.CreateRefund(ctx, channelCreateRefundRequest{ ThirdOrderID: originalPayment.ThirdOrderID, ProviderOrderID: refundOriginProviderOrderID(originalPayment), MerchantRefundID: merchantRefundID, RefundAmountCent: refundAmountCent, NotifyURL: runtimeConfig.NotifyURL, Attach: originalPayment.OrderNo, Remark: remark, }) if err != nil { _ = r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": err.Error()}) log.Printf("[payment] refund request failed order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d err=%v", orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, err) return nil, err } if !resp.OK { _ = r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, resp.Raw) log.Printf("[payment] refund rejected order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d code=%s message=%s", orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage) return nil, ErrPaymentUnavailable } refundStatus := "refunding" var paidAt *time.Time if resp.Status == "refunded" { refundStatus = "refunded" now := time.Now() paidAt = &now } else if resp.Status == "failed" { refundStatus = "failed" } if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", refundOrder.ID).Updates(map[string]any{ "status": refundStatus, "provider_order_id": resp.ProviderRefundID, "raw_request": jsonMap(resp.RawRequest), "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), "paid_at": paidAt, }).Error; err != nil { return nil, err } if refundStatus == "refunded" { _ = r.updateOrderRefundStatus(ctx, orderID, refundAmountCent) refundOrder.PaidAt = paidAt } else if refundStatus == "failed" { _ = r.markOrderRefundFailed(ctx, orderID, refundAmountCent) } else { _ = r.markOrderRefunding(ctx, orderID, refundAmountCent) } refundOrder.Status = refundStatus refundOrder.ProviderOrderID = resp.ProviderRefundID log.Printf("[payment] refund result order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d status=%s provider_refund_id=%s", orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, refundStatus, resp.ProviderRefundID) dto := toRefundDTO(refundOrder) return &dto, nil } func (r *Repository) QueryRefundStatus(ctx context.Context, orderID uint64) (*RefundDTO, error) { var payment model.PaymentOrder if err := r.db.WithContext(ctx).Where("order_id = ? AND biz_type IN ?", orderID, refundBizTypes).Order("id DESC").First(&payment).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrPaymentNotFound } return nil, err } runtimeConfig, err := r.runtimeConfigForPayment(ctx, &payment) if err != nil { return nil, ErrPaymentUnavailable } if payment.Status == "refunded" || payment.Status == "failed" || runtimeConfig.isMockMode() { dto := toRefundDTO(payment) return &dto, nil } if runtimeConfig.Channel == nil { return nil, ErrPaymentUnavailable } resp, err := runtimeConfig.Channel.QueryRefund(ctx, channelQueryRefundRequest{ ThirdOrderID: payment.ThirdOrderID, MerchantRefundID: payment.ThirdOrderID, ProviderRefundID: payment.ProviderOrderID, }) if err != nil { return nil, err } if resp.Status == "refunded" { now := time.Now() if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ "status": "refunded", "paid_at": now, "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceQuery)), }).Error; err != nil { return nil, err } payment.Status = "refunded" payment.PaidAt = &now _ = r.updateOrderRefundStatus(ctx, orderID, payment.AmountCent) } else if resp.Status == "failed" { if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ "status": "failed", "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceQuery)), }).Error; err != nil { return nil, err } payment.Status = "failed" _ = r.markOrderRefundFailed(ctx, orderID, payment.AmountCent) } dto := toRefundDTO(payment) return &dto, nil } func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64, refundAmountCent int64) error { now := time.Now() return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ "refund_status": "refunded", "refund_amount_cent": refundAmountCent, "refunded_at": now, }).Error } func (r *Repository) markOrderRefunding(ctx context.Context, orderID uint64, refundAmountCent int64) error { return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ "refund_status": "refunding", "refund_amount_cent": refundAmountCent, "refunded_at": nil, }).Error } func (r *Repository) markOrderRefundFailed(ctx context.Context, orderID uint64, refundAmountCent int64) error { return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ "refund_status": "failed", "refund_amount_cent": refundAmountCent, "refunded_at": nil, }).Error } func (r *Repository) markRefundFailed(ctx context.Context, paymentID uint64, orderID uint64, refundAmountCent int64, raw map[string]string) error { if raw == nil { raw = map[string]string{"error": "refund failed"} } if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(map[string]any{ "status": "failed", "raw_response": jsonMap(raw), }).Error; err != nil { return err } return r.markOrderRefundFailed(ctx, orderID, refundAmountCent) } func toRefundDTO(payment model.PaymentOrder) RefundDTO { return RefundDTO{ ID: payment.ID, PaymentNo: payment.PaymentNo, OrderID: payment.OrderID, OrderNo: payment.OrderNo, BizType: payment.BizType, AmountCent: payment.AmountCent, Status: payment.Status, ProviderOrderID: payment.ProviderOrderID, PaidAt: payment.PaidAt, CreatedAt: payment.CreatedAt, UpdatedAt: payment.UpdatedAt, } } func refundOriginProviderOrderID(payment model.PaymentOrder) string { if payment.Provider != "lakala" { return payment.ProviderOrderID } if tradeID := lakalaOriginTradeID(payment.RawResponse); tradeID != "" { return tradeID } return payment.ProviderOrderID } func lakalaOriginTradeID(raw datatypes.JSON) string { if len(raw) == 0 { return "" } var payload map[string]any if err := json.Unmarshal(raw, &payload); err != nil { return "" } if tradeID := firstStringValue(payload, "trade_no", "origin_trade_no"); tradeID != "" { return tradeID } value, ok := payload["order_trade_info_list"] if !ok { return "" } switch typed := value.(type) { case string: var items []map[string]any if err := json.Unmarshal([]byte(typed), &items); err != nil { return "" } for _, item := range items { if tradeID := firstStringValue(item, "trade_no", "origin_trade_no"); tradeID != "" { return tradeID } } case []any: for _, item := range typed { itemMap, ok := item.(map[string]any) if !ok { continue } if tradeID := firstStringValue(itemMap, "trade_no", "origin_trade_no"); tradeID != "" { return tradeID } } } return "" } func firstStringValue(values map[string]any, keys ...string) string { for _, key := range keys { value, ok := values[key] if !ok { continue } if text, ok := value.(string); ok && text != "" { return text } } return "" }