diff --git a/internal/application/employeecollection/refund_offset.go b/internal/application/employeecollection/refund_offset.go index 9cd7ce0d..e2ff1922 100644 --- a/internal/application/employeecollection/refund_offset.go +++ b/internal/application/employeecollection/refund_offset.go @@ -41,9 +41,13 @@ func (s *RefundOffsetService) ApplyInTx(ctx context.Context, tx *gorm.DB, source if s == nil || tx == nil || s.audit == nil { return errors.New(errors.CodeInternalError, "员工代收款退款冲销能力未配置") } - if source.RefundID == 0 || source.OrderID == 0 || source.RefundAmount <= 0 { + if source.RefundID == 0 || source.OrderID == 0 || source.RefundAmount < 0 { return errors.New(errors.CodeInvalidParam, "员工代收款退款冲销参数无效") } + // 0 元退款只作废权益、不退钱:不冲减、不关闭账单、不写冲销记录,也不能让退款事务失败。 + if source.RefundAmount == 0 { + return nil + } var bill model.EmployeeCollectionBill if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). Where("source_key = ?", employeecollectiondomain.OrderSourceKey(source.OrderID)). diff --git a/internal/model/dto/refund_dto.go b/internal/model/dto/refund_dto.go index bc4acd42..dc5eb50f 100644 --- a/internal/model/dto/refund_dto.go +++ b/internal/model/dto/refund_dto.go @@ -6,7 +6,7 @@ type CreateRefundRequest struct { ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty" description:"已废弃:实收金额由系统从原成功支付记录或订单实际收款派生并冻结,提交人填写无效"` Method string `json:"method" validate:"required,oneof=original_route customer_account asset_wallet agent_wallet" required:"true" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包)"` CustomerAccountInfo string `json:"customer_account_info" validate:"omitempty,max=1000" maxLength:"1000" description:"客户收款信息,仅客户收款信息退款方式必填,不得复用公司线下收款方式字典"` - RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"` + RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"min=0" required:"true" minimum:"0" description:"申请退款金额(分),0 ~ 冻结实收金额;0 元退款表示作废权益不退钱,仅限客户收款信息方式(钱包支付订单不支持),此时客户收款信息可不填但凭证必传"` RefundVoucherKey []string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(最多5个,仅客户收款信息退款方式必填,通过/storage/upload-url上传图片后获得)"` RefundReason string `json:"refund_reason" validate:"required,max=1000" required:"true" maxLength:"1000" description:"退款原因(必填,去除首尾空白后不得为空)"` // Remark 是申请人备注,随本次提交冻结进当次审批尝试快照;与审批备注(详情响应中的 remark)语义不同。 @@ -32,7 +32,7 @@ type ResubmitRefundRequest struct { ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"已废弃:实收金额由系统从原成功支付记录或订单实际收款派生并冻结,提交人填写无效"` Method *string `json:"method" validate:"omitempty,oneof=original_route customer_account asset_wallet agent_wallet" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包),不填沿用原有方式"` CustomerAccountInfo *string `json:"customer_account_info" validate:"omitempty,max=1000" maxLength:"1000" description:"客户收款信息,仅客户收款信息退款方式必填,不得复用公司线下收款方式字典"` - RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"` + RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=0" minimum:"0" description:"申请退款金额(分),不填沿用原金额;0 元退款规则同创建:仅限客户收款信息方式(钱包支付订单不支持),客户收款信息可不填但凭证必传"` RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(重新提交时可替换;客户收款信息退款方式必填,最多5个)"` RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因(不填沿用原有原因;重提时去除首尾空白后不得为空)"` // Remark 是本次重提的申请人备注,随本次尝试冻结;不填沿用该退款单最近一次尝试的备注。 diff --git a/internal/service/refund/clawback.go b/internal/service/refund/clawback.go index 1a51ab2b..eae6bbea 100644 --- a/internal/service/refund/clawback.go +++ b/internal/service/refund/clawback.go @@ -63,6 +63,15 @@ func (s *Service) clawbackCommission(ctx context.Context, refundID uint) error { if refund.Method == constants.RefundMethodOriginalRoute && refund.ChannelRefundStatus != constants.RefundChannelStatusSucceeded { return errors.New(errors.CodeServiceUnavailable, "原路退款渠道结果尚未明确成功,暂不生成佣金回溯") } + // 0 元退款只作废权益、不退钱,佣金不处理:与订单佣金是否已终态无关,直接闭合为无需回溯, + // 不能落到下方「成功金额非正」的可恢复失败而反复重试。原路退款的成功金额由渠道决定,不走这里。 + if isZeroAmountRefund(&refund) { + return s.settleClawback(ctx, &refund, constants.AuditActionRefundClawbackNotRequired, + "0 元退款无需回溯佣金", clawbackEventID(refund.ID, "clawback-not-required"), + map[string]any{ + "order_id": refund.OrderID, "zero_amount_refund": true, "clawback_required": false, + }) + } // 订单佣金未终态时等待:此时「无已发放佣金」只代表尚未计算完成或尚待人工修正, // 提前判定为无需回溯会让后续入账的佣金永久失去回溯机会,违反「MUST NOT 提前判定为无需回溯」。 @@ -171,6 +180,12 @@ func (s *Service) clawbackCommission(ctx context.Context, refundID uint) error { return s.applyClawback(ctx, &refund, allocations) } +// isZeroAmountRefund 判定已通过的退款是否为 0 元退款:非原路方式(含方式为空的存量申请)且审批退款金额为 0。 +func isZeroAmountRefund(refund *model.RefundRequest) bool { + return refund.Method != constants.RefundMethodOriginalRoute && + refund.ApprovedRefundAmount != nil && *refund.ApprovedRefundAmount == 0 +} + // clawbackEventID 生成回溯审计事件的稳定键,重复执行返回同一事件,审计写入因此天然幂等。 func clawbackEventID(refundID uint, suffix string) string { return "refund:" + strconv.FormatUint(uint64(refundID), 10) + ":" + suffix diff --git a/internal/service/refund/method.go b/internal/service/refund/method.go index 8d4cf9cb..cc430047 100644 --- a/internal/service/refund/method.go +++ b/internal/service/refund/method.go @@ -36,7 +36,8 @@ type refundMethodDecision struct { // 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, "退款来源订单无效") @@ -81,7 +82,7 @@ func (s *Service) decideRefundMethods(ctx context.Context, order *model.Order) ( return nil, errors.New(errors.CodeInvalidParam, "钱包支付订单的买家类型不支持退款") } case model.PaymentMethodOffline: - amount, err := orderActualPaidAmount(order) + amount, err := offlineActualPaidAmount(order) if err != nil { return nil, err } @@ -103,6 +104,14 @@ func orderActualPaidAmount(order *model.Order) (int64, error) { 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 @@ -219,12 +228,13 @@ func validateRefundMethod(decision *refundMethodDecision, method string) error { } // validateCustomerAccountMaterial 校验客户收款信息方式的材料完整性。 -// 客户收款信息与至少一个客户凭证附件必须同时存在;不得复用公司员工收款方式字典。 -func validateCustomerAccountMaterial(method, customerAccountInfo string, voucherKeys []string) error { +// 正数退款时客户收款信息与至少一个客户凭证附件必须同时存在;0 元退款不退钱,客户收款信息可为空, +// 但仍须至少一个客户凭证附件。不得复用公司员工收款方式字典。 +func validateCustomerAccountMaterial(method string, amount int64, customerAccountInfo string, voucherKeys []string) error { if method != constants.RefundMethodCustomerAccount { return nil } - if strings.TrimSpace(customerAccountInfo) == "" { + if amount > 0 && strings.TrimSpace(customerAccountInfo) == "" { return errors.New(errors.CodeInvalidParam, "客户收款信息退款必须填写客户收款信息") } hasVoucher := false @@ -240,13 +250,13 @@ func validateCustomerAccountMaterial(method, customerAccountInfo string, voucher return nil } -// validateFrozenRefundAmount 校验退款金额为正分且不超过冻结实收金额。 +// validateFrozenRefundAmount 校验退款金额不为负且不超过冻结实收金额;0 元退款表示作废权益、不退钱。 func validateFrozenRefundAmount(amount, frozenActualReceivedAmount int64) error { - if amount <= 0 { - return errors.New(errors.CodeInvalidParam, "退款金额必须为正分") + if amount < 0 { + return errors.New(errors.CodeInvalidParam, "退款金额不能为负") } - if frozenActualReceivedAmount <= 0 { - return errors.New(errors.CodeInvalidParam, "冻结实收金额缺失或非正,无法受理退款申请") + if frozenActualReceivedAmount < 0 { + return errors.New(errors.CodeInvalidParam, "冻结实收金额为负,无法受理退款申请") } if amount > frozenActualReceivedAmount { return errors.New(errors.CodeInvalidParam, "退款金额不能超过冻结实收金额") @@ -254,6 +264,15 @@ func validateFrozenRefundAmount(amount, frozenActualReceivedAmount int64) error 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 仅在原路退款方式下按冻结商户派生本次尝试的渠道退款请求号。 // 请求号由收口审批申请用例冻结到不可变尝试记录上,同一尝试的渠道重试复用它作为幂等标识, // 重提生成新值;非原路方式返回空串。 diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 789906bc..2c93caf6 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -175,11 +175,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt if err := validateFrozenRefundAmount(req.RequestedRefundAmount, decision.FrozenActualReceivedAmount); err != nil { return nil, err } + if err := validateZeroAmountMethod(req.RequestedRefundAmount, req.Method); err != nil { + return nil, err + } refundVoucherKey, err := normalizeRefundVoucherKey(req.RefundVoucherKey) if err != nil { return nil, err } - if err := validateCustomerAccountMaterial(req.Method, req.CustomerAccountInfo, refundVoucherKey); err != nil { + if err := validateCustomerAccountMaterial(req.Method, req.RequestedRefundAmount, req.CustomerAccountInfo, refundVoucherKey); err != nil { return nil, err } @@ -860,6 +863,10 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) return err } + if err := validateZeroAmountMethod(requestedRefundAmount, method); err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) + return err + } refundVoucherKey := refund.RefundVoucherKey if req.RefundVoucherKey != nil { normalized, normErr := normalizeRefundVoucherKey(*req.RefundVoucherKey) @@ -884,7 +891,7 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund if req.CustomerAccountInfo != nil { customerAccountInfo = *req.CustomerAccountInfo } - if err := validateCustomerAccountMaterial(method, customerAccountInfo, refundVoucherKey); err != nil { + if err := validateCustomerAccountMaterial(method, requestedRefundAmount, customerAccountInfo, refundVoucherKey); err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err) return err }