All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
- decideRefundMethods 将 fuiou 与 wechat、alipay 按线上支付处理,修复富友订单报"该订单的支付方式不支持退款" - 客户收款信息退款仅在金额大于 0 时强制映射并提交客户收款信息与凭证;0 元退款允许客户收款信息为空 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
13 KiB
Go
312 lines
13 KiB
Go
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 派生权威实收金额并按来源实际支付方式生成可选退款方式。
|
||
//
|
||
// 派生来源:线上支付取该订单原成功支付记录金额;资产钱包、代理主钱包与后台线下套餐订单
|
||
// 取订单实际收款或实际扣款金额。无法确定或金额非正时拒绝,绝不允许提交人以自填金额替代;
|
||
// 唯一例外是后台线下订单实收为 0(如迁移订单),冻结实收取 0,只能申请 0 元退款。
|
||
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 {
|
||
// 富友是微信支付的上游通道,套餐订单回调写入 fuiou,按线上支付处理。
|
||
case model.PaymentMethodWechat, model.PaymentMethodAlipay, model.ProviderTypeFuiou:
|
||
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 := offlineActualPaidAmount(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
|
||
}
|
||
|
||
// offlineActualPaidAmount 取后台线下订单的实际收款金额:允许为 0(只能申请 0 元退款),缺失或为负时拒绝。
|
||
func offlineActualPaidAmount(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 校验客户收款信息方式的材料完整性。
|
||
// 正数退款时客户收款信息与至少一个客户凭证附件必须同时存在;0 元退款不退钱,客户收款信息可为空,
|
||
// 但仍须至少一个客户凭证附件。不得复用公司员工收款方式字典。
|
||
func validateCustomerAccountMaterial(method string, amount int64, customerAccountInfo string, voucherKeys []string) error {
|
||
if method != constants.RefundMethodCustomerAccount {
|
||
return nil
|
||
}
|
||
if amount > 0 && 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 校验退款金额不为负且不超过冻结实收金额;0 元退款表示作废权益、不退钱。
|
||
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
|
||
}
|
||
|
||
// validateZeroAmountMethod 校验 0 元退款只能选客户收款信息方式:原路退款与退回原钱包都要动钱。
|
||
// 钱包支付订单的方式矩阵不含客户收款信息,因此本校验同时保证钱包订单不能申请 0 元退款。
|
||
func validateZeroAmountMethod(amount int64, method string) error {
|
||
if amount == 0 && strings.TrimSpace(method) != constants.RefundMethodCustomerAccount {
|
||
return errors.New(errors.CodeInvalidParam, "0 元退款仅支持客户收款信息方式")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// buildChannelRefundRequestNo 仅在原路退款方式下按冻结商户派生本次尝试的渠道退款请求号。
|
||
// 请求号由收口审批申请用例冻结到不可变尝试记录上,同一尝试的渠道重试复用它作为幂等标识,
|
||
// 重提生成新值;非原路方式返回空串。
|
||
func buildChannelRefundRequestNo(method string, config *model.WechatConfig) string {
|
||
if method != constants.RefundMethodOriginalRoute {
|
||
return ""
|
||
}
|
||
return requestChannelRefundNo(config, time.Now())
|
||
}
|
||
|
||
// frozenSourcePaymentNo 取原成功支付记录的支付单号作为冻结的来源支付单号。
|
||
// 线下订单没有线上支付记录(payment 为 nil),返回空串以空值保存,不阻断申请创建。
|
||
func frozenSourcePaymentNo(payment *model.Payment) string {
|
||
if payment == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(payment.PaymentNo)
|
||
}
|
||
|
||
// frozenOriginalChannelTradeNo 取原成功支付记录的渠道交易流水号作为冻结的原支付渠道交易流水号。
|
||
// 线下订单没有线上支付记录(payment 为 nil 或流水号缺失),返回空串以空值保存。
|
||
func frozenOriginalChannelTradeNo(payment *model.Payment) string {
|
||
if payment == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(payment.ThirdPartyTradeNo)
|
||
}
|
||
|
||
// applicantRemark 归一化请求中的申请人备注:缺省与纯空白一律视为未填写。
|
||
func applicantRemark(value *string) string {
|
||
if value == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(*value)
|
||
}
|