Files
junhong_cmp_fiber/internal/service/refund/method.go
break ba0855d9eb feat(退款): AUG26-006 退款方式选择与原路退款
按 PRD 2.3/2.4/2.5 落地套餐退款的方式矩阵与原路渠道退款:

- 退款申请派生并冻结权威实收金额(线上取原成功支付记录,钱包/线下取订单实际收款),
  提交人不可填写或修改;按来源支付方式生成可选方式矩阵并在创建、提交、执行前重复校验。
- 审批切换为「每次提交一条不可变审批尝试记录 + 独立企业微信审批实例」,业务标识取尝试
  记录主键;终态消费按尝试记录优先、退款申请兜底双读,兼容存量无实例与已关联实例申请。
  新增活动退款部分唯一索引 (order_id) WHERE status IN (1,5,6)。
- 本地人工终审保持既有开关,补齐通过入口的 approval_instance_id IS NULL 守卫,使三个
  入口一致拒绝已关联审批实例的申请;重提按尝试模式重写(仅已拒绝/已退回/原路失败且无异常)。
- 权益时点:企微通过事务写退款终态、按方式确定的订单态、钱包回款、员工账单冲销与可靠
  失效事实;套餐失效/接续/停机仍由既有可靠机制最终一致执行,不把外部调用放入资金事务。
  订单支付状态按方式置位:凭证退款与退回原钱包在企微通过时置已退款,原路须渠道明确成功。
- 按官方契约实现微信直连 v3、微信 v2(双向证书)、富友(/commonRefund 与 /refundQuery)、
  支付宝四类原路退款;能力只由服务商类型与退款必需凭证完整性决定,无人工开关。
  渠道请求号在提交时冻结到尝试记录,并以 channel_submitted_at 条件认领保证资金动作至多
  提交一次(重复投递只查询不二次提交);不向任何渠道传递退款结果通知地址。
- 新增 refund:channel:recovery 恢复任务只查询回填;本地查询窗口超期(富友 72 小时、
  微信 v2 7 天)转原路退款失败、渠道状态已失败、分类超时未知并置异常转人工,不放行自动
  重提以避免重复退款。
- 同步退款 DTO/导出/审计资源与审计查询关联、商户凭证文档,并修正 fuiou 集成契约文档。

迁移 000218(退款尝试与渠道退款事实)、000219(微信 v2 客户端证书凭证)成对提供,
未修改既有迁移;测试库 junhong_cmp_test 完成 up/down/up 与行为核对,未调用真实渠道。
2026-09-14 11:55:16 +08:00

274 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}