feat: 支持 0 元退款(仅作废权益不退钱)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
邹进
2026-10-22 11:17:29 +09:00
parent 44a05cf687
commit 1ffc923b94
5 changed files with 60 additions and 15 deletions

View File

@@ -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)).

View File

@@ -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 是本次重提的申请人备注,随本次尝试冻结;不填沿用该退款单最近一次尝试的备注。

View File

@@ -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

View File

@@ -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 仅在原路退款方式下按冻结商户派生本次尝试的渠道退款请求号。
// 请求号由收口审批申请用例冻结到不可变尝试记录上,同一尝试的渠道重试复用它作为幂等标识,
// 重提生成新值;非原路方式返回空串。

View File

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