Files
hfb_sys/backend/internal/modules/payment/refund.go
T

311 lines
11 KiB
Go

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 ""
}