package refund import ( "context" "strings" "time" "gorm.io/gorm" refundchannel "github.com/break/junhong_cmp_fiber/internal/application/refundchannel" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // refundMethodOption 描述一种可选退款方式及其不可用的原因。 type refundMethodOption struct { Method string Name string Available bool // Reason 在方式不可用时说明具体原因,供前端禁用并展示。 Reason string } // refundMethodDecision 是一次退款方式判定的完整结果。 type refundMethodDecision struct { Options []refundMethodOption // FrozenActualReceivedAmount 是系统派生的权威实收金额(分),提交人不可填写或修改。 FrozenActualReceivedAmount int64 // Payment 是派生实收金额时使用的原成功支付记录,线上订单才有值。 Payment *model.Payment // ChannelConfig 是原路退款实际使用的冻结商户当前凭证,非线上订单或原路不可用时为 nil。 ChannelConfig *model.WechatConfig } // decideRefundMethods 派生权威实收金额并按来源实际支付方式生成可选退款方式。 // // 派生来源:线上支付取该订单原成功支付记录金额;资产钱包、代理主钱包与后台线下套餐订单 // 取订单实际收款或实际扣款金额。无法确定或金额非正时拒绝,绝不允许提交人以自填金额替代。 func (s *Service) decideRefundMethods(ctx context.Context, order *model.Order) (*refundMethodDecision, error) { if order == nil || order.ID == 0 { return nil, errors.New(errors.CodeInvalidParam, "退款来源订单无效") } decision := &refundMethodDecision{} switch order.PaymentMethod { case model.PaymentMethodWechat, model.PaymentMethodAlipay: payment, err := s.loadPaidPackagePayment(ctx, order.ID) if err != nil { return nil, err } if payment.Amount <= 0 { return nil, errors.New(errors.CodeInvalidParam, "原成功支付记录金额非正,无法确定权威实收金额") } decision.Payment = payment decision.FrozenActualReceivedAmount = payment.Amount originalRoute, reason, config := s.originalRouteAvailability(ctx, order, payment) if originalRoute { decision.ChannelConfig = config } decision.Options = []refundMethodOption{ {Method: constants.RefundMethodOriginalRoute, Name: constants.RefundMethodName(constants.RefundMethodOriginalRoute), Available: originalRoute, Reason: reason}, {Method: constants.RefundMethodCustomerAccount, Name: constants.RefundMethodName(constants.RefundMethodCustomerAccount), Available: true}, } case model.PaymentMethodWallet: amount, err := orderActualPaidAmount(order) if err != nil { return nil, err } decision.FrozenActualReceivedAmount = amount switch order.BuyerType { case model.BuyerTypeAgent: decision.Options = []refundMethodOption{ {Method: constants.RefundMethodAgentWallet, Name: constants.RefundMethodName(constants.RefundMethodAgentWallet), Available: true}, } case model.BuyerTypePersonal: decision.Options = []refundMethodOption{ {Method: constants.RefundMethodAssetWallet, Name: constants.RefundMethodName(constants.RefundMethodAssetWallet), Available: true}, } default: return nil, errors.New(errors.CodeInvalidParam, "钱包支付订单的买家类型不支持退款") } case model.PaymentMethodOffline: amount, err := orderActualPaidAmount(order) if err != nil { return nil, err } decision.FrozenActualReceivedAmount = amount decision.Options = []refundMethodOption{ {Method: constants.RefundMethodCustomerAccount, Name: constants.RefundMethodName(constants.RefundMethodCustomerAccount), Available: true}, } default: return nil, errors.New(errors.CodeInvalidParam, "该订单的支付方式不支持退款") } return decision, nil } // orderActualPaidAmount 取订单实际收款或实际扣款金额作为权威实收金额。 func orderActualPaidAmount(order *model.Order) (int64, error) { if order.ActualPaidAmount == nil || *order.ActualPaidAmount <= 0 { return 0, errors.New(errors.CodeInvalidParam, "订单实际收款金额缺失或非正,无法确定权威实收金额") } return *order.ActualPaidAmount, nil } // loadPaidPackagePayment 读取该订单原成功的线上支付记录。 func (s *Service) loadPaidPackagePayment(ctx context.Context, orderID uint) (*model.Payment, error) { var payment model.Payment err := s.db.WithContext(ctx). Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid). Order("id DESC"). First(&payment).Error if err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeInvalidParam, "未找到该订单的原成功支付记录,无法确定权威实收金额") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联支付记录失败") } return &payment, nil } // originalRouteAvailability 预检线上订单的原路退款可退性,并返回实际使用的商户凭证。 // // 条件全部本地可判定:存在成功支付记录、可定位实际收款商户、该商户具备其服务商类型的 // 退款能力与必需凭证、且原交易未超出渠道可退时限。实际调用渠道后的结果才是退款资格的 // 最终判断;预检只用于在申请阶段禁用原路并说明原因。 func (s *Service) originalRouteAvailability(ctx context.Context, order *model.Order, payment *model.Payment) (bool, string, *model.WechatConfig) { if payment == nil { return false, "缺少原成功支付记录", nil } if strings.TrimSpace(payment.ThirdPartyTradeNo) == "" { return false, "原支付记录缺少渠道交易流水号", nil } if s.paymentMerchantRuntime == nil { return false, "商户凭证加载能力未配置", nil } var config *model.WechatConfig if payment.MerchantID != nil { merchant, err := s.paymentMerchantRuntime.LoadMerchant(ctx, *payment.MerchantID) if err != nil { return false, "实际收款商户不可用", nil } // 商户停用不阻断历史单的原路退款,因此只校验凭证完整性,不校验商户状态。 config, err = merchantRefundConfig(merchant) if err != nil { return false, err.Error(), nil } } else { if payment.PaymentConfigID == nil { return false, "历史支付单缺少支付配置", nil } legacy, err := loadLegacyPaymentConfig(ctx, s.db, *payment.PaymentConfigID) if err != nil { return false, err.Error(), nil } config = legacy } if reason := channelRefundCredentialIssue(config); reason != "" { return false, reason, nil } if reason := channelRefundWindowIssue(config.ProviderType, payment.PaidAt); reason != "" { return false, reason, nil } return true, "", config } // channelRefundWindowIssue 判定原交易是否超出渠道可退时限。 // 富友按是否回传原交易日期区分:不回传仅支持 30 天内,回传可退 360 天内。 // 系统始终回传原交易日期,因此按 360 天判定;微信与支付宝不设本地时限。 func channelRefundWindowIssue(providerType string, paidAt *time.Time) string { if providerType != model.ProviderTypeFuiou { return "" } if paidAt == nil { return "富友原交易缺少支付时间,无法回传原交易日期" } if time.Since(*paidAt) > 360*24*time.Hour { return "富友原交易已超出渠道可退时限(360 天)" } return "" } // requestChannelRefundNo 按冻结商户生成渠道退款请求号,并在提交时冻结到审批尝试记录。 // 重提会生成新值;同一尝试的渠道重试复用该值,因此渠道侧以它作为幂等标识。 func requestChannelRefundNo(config *model.WechatConfig, now time.Time) string { return refundchannel.BuildChannelRefundRequestNo(merchantChannelPrefix(config), now) } // merchantChannelPrefix 取商户标识中的数字段作为请求号前缀。 // 富友要求前缀为其机构码;其余渠道只要求请求号在其长度与字符集约束内唯一。 func merchantChannelPrefix(config *model.WechatConfig) string { if config == nil { return "0000" } if config.ProviderType == model.ProviderTypeFuiou && strings.TrimSpace(config.FyInsCd) != "" { return config.FyInsCd } return config.WxMchID + config.AliAppID } // validateRefundMethod 校验提交的退款方式属于该订单的可选方式集合。 func validateRefundMethod(decision *refundMethodDecision, method string) error { method = strings.TrimSpace(method) if method == "" { return errors.New(errors.CodeInvalidParam, "退款方式不能为空") } for _, option := range decision.Options { if option.Method != method { continue } if !option.Available { return errors.New(errors.CodeInvalidParam, "该订单当前不支持此退款方式:"+option.Reason) } return nil } return errors.New(errors.CodeInvalidParam, "该订单不支持此退款方式") } // validateCustomerAccountMaterial 校验客户收款信息方式的材料完整性。 // 客户收款信息与至少一个客户凭证附件必须同时存在;不得复用公司员工收款方式字典。 func validateCustomerAccountMaterial(method, customerAccountInfo string, voucherKeys []string) error { if method != constants.RefundMethodCustomerAccount { return nil } if strings.TrimSpace(customerAccountInfo) == "" { return errors.New(errors.CodeInvalidParam, "客户收款信息退款必须填写客户收款信息") } hasVoucher := false for _, key := range voucherKeys { if strings.TrimSpace(key) != "" { hasVoucher = true break } } if !hasVoucher { return errors.New(errors.CodeInvalidParam, "客户收款信息退款必须至少上传一个客户收款凭证") } return nil } // validateFrozenRefundAmount 校验退款金额为正分且不超过冻结实收金额。 func validateFrozenRefundAmount(amount, frozenActualReceivedAmount int64) error { if amount <= 0 { return errors.New(errors.CodeInvalidParam, "退款金额必须为正分") } if frozenActualReceivedAmount <= 0 { return errors.New(errors.CodeInvalidParam, "冻结实收金额缺失或非正,无法受理退款申请") } if amount > frozenActualReceivedAmount { return errors.New(errors.CodeInvalidParam, "退款金额不能超过冻结实收金额") } return nil } // buildAttemptFromDecision 依据方式判定结果构造一条不可变审批尝试记录。 func buildAttemptFromDecision(decision *refundMethodDecision, refund *model.RefundRequest, config *model.WechatConfig, now time.Time) *model.RefundRequestAttempt { attempt := &model.RefundRequestAttempt{ RefundID: refund.ID, Method: refund.Method, RefundAmount: refund.RequestedRefundAmount, FrozenActualReceivedAmount: decision.FrozenActualReceivedAmount, RefundReason: refund.RefundReason, CustomerAccountInfo: refund.CustomerAccountInfo, CustomerVoucherKeys: refund.RefundVoucherKey, SubmittedByAccountID: refund.Creator, } if refund.Method == constants.RefundMethodOriginalRoute { attempt.ChannelRefundRequestNo = requestChannelRefundNo(config, now) } return attempt }