添加订单押金暂扣与归还
This commit is contained in:
@@ -29,6 +29,12 @@ type RentalOrder struct {
|
||||
RefundStatus string `gorm:"size:32;not null;default:'none';index" json:"refund_status"`
|
||||
RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"`
|
||||
RefundedAt *time.Time `json:"refunded_at"`
|
||||
DepositHoldStatus string `gorm:"size:16;not null;default:'none';index" json:"deposit_hold_status"`
|
||||
DepositHoldAmountCent int64 `gorm:"not null;default:0" json:"deposit_hold_amount_cent"`
|
||||
DepositHoldReason string `gorm:"size:255;not null;default:''" json:"deposit_hold_reason"`
|
||||
DepositHeldBy *uint64 `json:"deposit_held_by"`
|
||||
DepositHeldAt *time.Time `json:"deposit_held_at"`
|
||||
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at"`
|
||||
OwnerSettledAt *time.Time `json:"owner_settled_at"`
|
||||
SettledAt *time.Time `json:"settled_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -38,3 +44,45 @@ type RentalOrder struct {
|
||||
func (RentalOrder) TableName() string {
|
||||
return "rental_orders"
|
||||
}
|
||||
|
||||
// 押金暂扣状态:客服可对进行中订单的押金退款进行暂扣,订单照常结算,
|
||||
// 但本应原路退给租客的押金部分挂起不退,后续由客服手动归还。
|
||||
const (
|
||||
DepositHoldStatusNone = "none" // 未暂扣
|
||||
DepositHoldStatusHeld = "held" // 已暂扣(押金退款被拦截/挂起)
|
||||
DepositHoldStatusReleased = "released" // 已归还
|
||||
)
|
||||
|
||||
// IsDepositHeld 判断订单当前是否处于押金暂扣状态。
|
||||
func (o *RentalOrder) IsDepositHeld() bool {
|
||||
return o.DepositHoldStatus == DepositHoldStatusHeld
|
||||
}
|
||||
|
||||
// ApplyDepositHold 在任一结算退款路径中拦截押金退款。
|
||||
// totalRefundCent 是本次本应原路退还给租客的总金额(含租金退款和押金退款),
|
||||
// depositPortionCent 是其中属于押金退款的部分。
|
||||
// 若订单已暂扣,则把押金部分(不超过本次退款总额)从退款中扣除,
|
||||
// 并把尚未记录过的部分累加到暂扣金额(累计不超过实付押金),返回扣除后实际发起原路退款的金额;
|
||||
// 未暂扣时原样返回 totalRefundCent。
|
||||
// 注意:本函数只做“少退、挂起”,绝不多退,因此在任何路径下都不会造成租客损失或平台多付。
|
||||
func (o *RentalOrder) ApplyDepositHold(totalRefundCent, depositPortionCent int64) int64 {
|
||||
if !o.IsDepositHeld() {
|
||||
return totalRefundCent
|
||||
}
|
||||
withheld := depositPortionCent
|
||||
if withheld > totalRefundCent {
|
||||
withheld = totalRefundCent
|
||||
}
|
||||
if withheld <= 0 {
|
||||
return totalRefundCent
|
||||
}
|
||||
remainingDepositHoldCent := o.DepositAmountCent - o.DepositHoldAmountCent
|
||||
if remainingDepositHoldCent > 0 {
|
||||
newHold := withheld
|
||||
if newHold > remainingDepositHoldCent {
|
||||
newHold = remainingDepositHoldCent
|
||||
}
|
||||
o.DepositHoldAmountCent += newHold
|
||||
}
|
||||
return totalRefundCent - withheld
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRentalOrderApplyDepositHold(t *testing.T) {
|
||||
order := RentalOrder{
|
||||
DepositAmountCent: 1000,
|
||||
DepositHoldStatus: DepositHoldStatusHeld,
|
||||
}
|
||||
|
||||
actualRefundCent := order.ApplyDepositHold(1500, 1000)
|
||||
if actualRefundCent != 500 {
|
||||
t.Fatalf("actual refund = %d, want 500", actualRefundCent)
|
||||
}
|
||||
if order.DepositHoldAmountCent != 1000 {
|
||||
t.Fatalf("hold amount = %d, want 1000", order.DepositHoldAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRentalOrderApplyDepositHoldKeepsWithholdingAfterAmountRecorded(t *testing.T) {
|
||||
order := RentalOrder{
|
||||
DepositAmountCent: 1000,
|
||||
DepositHoldStatus: DepositHoldStatusHeld,
|
||||
DepositHoldAmountCent: 1000,
|
||||
}
|
||||
|
||||
actualRefundCent := order.ApplyDepositHold(1500, 1000)
|
||||
if actualRefundCent != 500 {
|
||||
t.Fatalf("actual refund = %d, want 500", actualRefundCent)
|
||||
}
|
||||
if order.DepositHoldAmountCent != 1000 {
|
||||
t.Fatalf("hold amount = %d, want 1000", order.DepositHoldAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRentalOrderApplyDepositHoldSkipsWhenNotHeld(t *testing.T) {
|
||||
order := RentalOrder{
|
||||
DepositAmountCent: 1000,
|
||||
DepositHoldStatus: DepositHoldStatusNone,
|
||||
}
|
||||
|
||||
actualRefundCent := order.ApplyDepositHold(1500, 1000)
|
||||
if actualRefundCent != 1500 {
|
||||
t.Fatalf("actual refund = %d, want 1500", actualRefundCent)
|
||||
}
|
||||
if order.DepositHoldAmountCent != 0 {
|
||||
t.Fatalf("hold amount = %d, want 0", order.DepositHoldAmountCent)
|
||||
}
|
||||
}
|
||||
@@ -79,8 +79,10 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
if err := wallet.AppendEntries(tx, settlement.Entries...); err != nil {
|
||||
return err
|
||||
}
|
||||
if settlement.RenterRefundAmountCent > 0 {
|
||||
action, err := r.prepareRefund(&order, settlement.RenterRefundAmountCent, "arbitration_refund", "仲裁退款原路退还")
|
||||
// 押金暂扣:若订单被暂扣,则把本次退款中的押金部分挂起不退,仅退还剩余部分。
|
||||
refundAmountCent := order.ApplyDepositHold(settlement.RenterRefundAmountCent, settlement.RenterDepositRefundCent)
|
||||
if refundAmountCent > 0 {
|
||||
action, err := r.prepareRefund(&order, refundAmountCent, "arbitration_refund", "仲裁退款原路退还")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -169,6 +171,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
type arbitrationSettlement struct {
|
||||
Entries []wallet.Entry
|
||||
RenterRefundAmountCent int64
|
||||
RenterDepositRefundCent int64
|
||||
OwnerIncomeAmountCent int64
|
||||
DepositDeductAmountCent int64
|
||||
}
|
||||
@@ -223,15 +226,19 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
|
||||
switch req.Result {
|
||||
case "full_refund":
|
||||
addRenterRefund(totalCent, "仲裁全额退款")
|
||||
settlement.RenterDepositRefundCent = depositAmountCent
|
||||
case "partial_refund":
|
||||
if req.AmountCent <= 0 || req.AmountCent > totalCent {
|
||||
return settlement, ErrInvalidDispute
|
||||
}
|
||||
addRenterRefund(req.AmountCent, "仲裁部分退款")
|
||||
// 部分退款为合并金额,无法精确拆分租金/押金,按押金优先归类以便暂扣。
|
||||
settlement.RenterDepositRefundCent = money.MinCent(req.AmountCent, depositAmountCent)
|
||||
addOwnerIncome(money.MinCent(totalCent-req.AmountCent, ownerRentAmountCent+depositAmountCent), "仲裁剩余金额结算给号主")
|
||||
case "release_deposit":
|
||||
addOwnerIncome(ownerRentAmountCent, "仲裁确认订单金额结算给号主")
|
||||
addRenterRefund(depositAmountCent, "仲裁释放押金给租客")
|
||||
settlement.RenterDepositRefundCent = depositAmountCent
|
||||
case "deduct_deposit", "compensate_owner":
|
||||
deductAmountCent := req.AmountCent
|
||||
if deductAmountCent <= 0 {
|
||||
@@ -243,6 +250,7 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest, r
|
||||
settlement.DepositDeductAmountCent = deductAmountCent
|
||||
addOwnerIncome(ownerRentAmountCent+deductAmountCent, "仲裁订单金额及押金赔付结算给号主")
|
||||
addRenterRefund(depositAmountCent-deductAmountCent, "仲裁退回剩余押金给租客")
|
||||
settlement.RenterDepositRefundCent = depositAmountCent - deductAmountCent
|
||||
case "order_close":
|
||||
// Only release frozen funds. No available-balance settlement happens in development mode.
|
||||
case "mark_abnormal":
|
||||
|
||||
@@ -38,7 +38,9 @@ func (r *Repository) AdminClose(ctx context.Context, adminID uint64, orderID uin
|
||||
archiveAssets(listing, account)
|
||||
if beforeOrderStatus != orderStatusPendingPayment {
|
||||
totalCent := order.RentAmountCent + order.DepositAmountCent
|
||||
action, err := r.prepareRefund(order, totalCent, refundBizAdminClose, "客服关闭订单原路退款")
|
||||
// 押金暂扣:若订单已暂扣,拦截退款中的押金部分挂起不退,其余照常原路退。
|
||||
refundAmountCent := order.ApplyDepositHold(totalCent, order.DepositAmountCent)
|
||||
action, err := r.prepareRefund(order, refundAmountCent, refundBizAdminClose, "客服关闭订单原路退款")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -307,26 +309,50 @@ func resetTargetForOrder(order model.RentalOrder) (adminResetTarget, bool) {
|
||||
|
||||
// AdminRefund 触发后台人工退款,退款由 payment 模块走渠道原路退回。
|
||||
func (r *Repository) AdminRefund(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) {
|
||||
var order model.RentalOrder
|
||||
db := r.db.WithContext(ctx)
|
||||
if err := db.First(&order, orderID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.RefundStatus == refundStatusRefunded {
|
||||
return r.buildRefundStatusDTO(&order), nil
|
||||
}
|
||||
if r.refundStarter == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
totalCent := order.RentAmountCent + order.DepositAmountCent
|
||||
if totalCent <= 0 {
|
||||
return nil, ErrInvalidCheckoutAmount
|
||||
}
|
||||
status, err := r.refundStarter.StartRefund(ctx, orderID, totalCent, refundBizAdmin, "后台人工退款")
|
||||
var refund *refundAction
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.RentalOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.RefundStatus == refundStatusRefunded {
|
||||
return nil
|
||||
}
|
||||
totalCent := order.RentAmountCent + order.DepositAmountCent
|
||||
if totalCent <= 0 {
|
||||
return ErrInvalidCheckoutAmount
|
||||
}
|
||||
// 押金暂扣:人工退款也必须尊重暂扣状态,避免绕过押金挂起直接全额退款。
|
||||
refundAmountCent := order.ApplyDepositHold(totalCent, order.DepositAmountCent)
|
||||
if refundAmountCent <= 0 {
|
||||
order.RefundStatus = "none"
|
||||
order.RefundAmountCent = 0
|
||||
return tx.Save(&order).Error
|
||||
}
|
||||
if r.refundStarter == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
action, err := r.prepareRefund(&order, refundAmountCent, refundBizAdmin, "后台人工退款")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refund = action
|
||||
return tx.Save(&order).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := ""
|
||||
if refund != nil {
|
||||
var err error
|
||||
status, err = r.refundStarter.StartRefund(ctx, refund.OrderID, refund.RefundAmountCent, refund.BizType, refund.Remark)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 重新读取订单,拿到 payment 模块更新后的退款字段。
|
||||
var order model.RentalOrder
|
||||
if err := db.First(&order, orderID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -371,7 +397,15 @@ func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) err
|
||||
if order.RefundAmountCent <= 0 {
|
||||
return errors.New("退款金额无效")
|
||||
}
|
||||
action, err := r.prepareRefund(&order, order.RefundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)")
|
||||
// 押金暂扣:若订单已暂扣,拦截退款中的押金部分挂起不退,其余照常原路退。
|
||||
refundAmountCent := order.ApplyDepositHold(order.RefundAmountCent, order.DepositAmountCent)
|
||||
if refundAmountCent <= 0 {
|
||||
// 全部押金被暂扣且无其他可退金额,直接标记为已处理,等待后续手动退押金。
|
||||
order.RefundStatus = "none"
|
||||
order.RefundAmountCent = 0
|
||||
return tx.Save(&order).Error
|
||||
}
|
||||
action, err := r.prepareRefund(&order, refundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -469,7 +503,9 @@ func (r *Repository) AdminSeal(ctx context.Context, adminID uint64, orderID uint
|
||||
sealAssets(listing, account)
|
||||
if beforeOrderStatus != orderStatusPendingPayment {
|
||||
totalCent := order.RentAmountCent + order.DepositAmountCent
|
||||
action, err := r.prepareRefund(order, totalCent, refundBizAdminSeal, "客服封存订单原路退款")
|
||||
// 押金暂扣:若订单已暂扣,拦截退款中的押金部分挂起不退,其余照常原路退。
|
||||
refundAmountCent := order.ApplyDepositHold(totalCent, order.DepositAmountCent)
|
||||
action, err := r.prepareRefund(order, refundAmountCent, refundBizAdminSeal, "客服封存订单原路退款")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -77,10 +77,12 @@ func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement
|
||||
|
||||
func (r *Repository) prepareCheckoutRefund(order *model.RentalOrder, settlement checkoutSettlement) (*refundAction, error) {
|
||||
renterRefundTotalCent := settlement.RentRefundCent + settlement.DepositRefundCent
|
||||
if renterRefundTotalCent <= 0 {
|
||||
// 押金暂扣:若订单已暂扣,拦截本次结账中应退还租客的押金部分挂起不退,其余(租金退款)照常原路退。
|
||||
actualRefundCent := order.ApplyDepositHold(renterRefundTotalCent, settlement.DepositRefundCent)
|
||||
if actualRefundCent <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return r.prepareRefund(order, renterRefundTotalCent, refundBizCheckout, "结账退款原路退还")
|
||||
return r.prepareRefund(order, actualRefundCent, refundBizCheckout, "结账退款原路退还")
|
||||
}
|
||||
|
||||
func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutSettlement) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package order
|
||||
|
||||
import "hfb_sys/backend/internal/model"
|
||||
|
||||
const (
|
||||
orderStatusPendingPayment = "pending_payment"
|
||||
orderStatusPendingHandoff = "pending_handoff"
|
||||
@@ -66,4 +68,9 @@ const (
|
||||
refundBizAdminSeal = "admin_seal_refund"
|
||||
refundBizAdmin = "admin_refund"
|
||||
refundBizCheckout = "checkout_refund"
|
||||
refundBizDeposit = "deposit_refund"
|
||||
|
||||
depositHoldStatusNone = model.DepositHoldStatusNone
|
||||
depositHoldStatusHeld = model.DepositHoldStatusHeld
|
||||
depositHoldStatusReleased = model.DepositHoldStatusReleased
|
||||
)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// AdminHoldDeposit 客服对进行中订单的押金进行暂扣。
|
||||
// 暂扣本身不移动任何资金:租客押金全程在支付渠道,系统只记录“暂扣中”标记;
|
||||
// 待订单结算/取消/关闭/仲裁时,本应原路退还租客的押金部分会被拦截挂起,
|
||||
// 后续由客服通过 AdminReleaseDeposit 手动原路归还。
|
||||
func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var order model.RentalOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !canHoldDeposit(order) {
|
||||
return ErrDepositCannotHold
|
||||
}
|
||||
beforeStatus := order.DepositHoldStatus
|
||||
now := time.Now()
|
||||
order.DepositHoldStatus = depositHoldStatusHeld
|
||||
order.DepositHoldReason = req.Reason
|
||||
order.DepositHeldBy = &adminID
|
||||
order.DepositHeldAt = &now
|
||||
order.DepositHoldReleasedAt = nil
|
||||
if err := appendAuditLog(tx, adminID, "order.deposit_hold", "order", order.ID, meta, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"order_no": order.OrderNo,
|
||||
"renter_id": order.RenterID,
|
||||
"reason": req.Reason,
|
||||
"before_hold_status": beforeStatus,
|
||||
"after_hold_status": order.DepositHoldStatus,
|
||||
"deposit_amount_cent": order.DepositAmountCent,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Save(&order).Error
|
||||
})
|
||||
}
|
||||
|
||||
// AdminReleaseDeposit 客服手动归还此前暂扣的押金,通过支付渠道原路退回。
|
||||
// 仅支持归还,不支持扣款(系统内无平台资金池承接扣款)。
|
||||
func (r *Repository) AdminReleaseDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||
var refund *refundAction
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var order model.RentalOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.DepositHoldStatus != depositHoldStatusHeld {
|
||||
return ErrDepositNotHeld
|
||||
}
|
||||
holdAmountCent := order.DepositHoldAmountCent
|
||||
if holdAmountCent <= 0 {
|
||||
return ErrDepositHoldAmountEmpty
|
||||
}
|
||||
now := time.Now()
|
||||
order.DepositHoldStatus = depositHoldStatusReleased
|
||||
order.DepositHoldReleasedAt = &now
|
||||
action, err := r.prepareRefund(&order, holdAmountCent, refundBizDeposit, "暂扣押金归还原路退回")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refund = action
|
||||
if err := appendAuditLog(tx, adminID, "order.deposit_release", "order", order.ID, meta, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"order_no": order.OrderNo,
|
||||
"renter_id": order.RenterID,
|
||||
"reason": req.Reason,
|
||||
"deposit_hold_amount_cent": holdAmountCent,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Save(&order).Error
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.startRefundBestEffort(ctx, refund)
|
||||
return nil
|
||||
}
|
||||
|
||||
// canHoldDeposit 判断订单当前是否允许暂扣押金:
|
||||
// 仅进行中(非终态、押金退款尚未发生)且有实付押金、且未处于暂扣态的订单。
|
||||
func canHoldDeposit(order model.RentalOrder) bool {
|
||||
if order.DepositHoldStatus != depositHoldStatusNone {
|
||||
return false
|
||||
}
|
||||
if order.DepositAmountCent <= 0 {
|
||||
return false
|
||||
}
|
||||
switch order.Status {
|
||||
case orderStatusPendingHandoff,
|
||||
orderStatusRenting,
|
||||
orderStatusOverdue,
|
||||
orderStatusPendingCheckoutConfirm,
|
||||
orderStatusPendingCheckoutAccept,
|
||||
orderStatusCheckoutDisputing:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,11 @@ type OrderDTO struct {
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
RefundStatus string `json:"refund_status,omitempty"`
|
||||
RefundAmountCent int64 `json:"refund_amount_cent"`
|
||||
DepositHoldStatus string `json:"deposit_hold_status,omitempty"`
|
||||
DepositHoldAmountCent int64 `json:"deposit_hold_amount_cent"`
|
||||
DepositHoldReason string `json:"deposit_hold_reason,omitempty"`
|
||||
DepositHeldAt *time.Time `json:"deposit_held_at,omitempty"`
|
||||
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at,omitempty"`
|
||||
ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"`
|
||||
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
||||
AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"`
|
||||
|
||||
@@ -74,6 +74,14 @@ func (h *Handler) AdminResetHandoff(c *gin.Context) {
|
||||
h.adminAction(c, h.service.AdminResetHandoff, gin.H{"reset": true})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminHoldDeposit(c *gin.Context) {
|
||||
h.adminAction(c, h.service.AdminHoldDeposit, gin.H{"held": true})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminReleaseDeposit(c *gin.Context) {
|
||||
h.adminAction(c, h.service.AdminReleaseDeposit, gin.H{"released": true})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminRefund(c *gin.Context) {
|
||||
orderID, ok := parseID(c)
|
||||
if !ok {
|
||||
|
||||
@@ -47,6 +47,12 @@ func writeOrderError(c *gin.Context, err error) {
|
||||
response.BadRequest(c, "结账金额不符合规则")
|
||||
case errors.Is(err, ErrPermissionDenied):
|
||||
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单")
|
||||
case errors.Is(err, ErrDepositCannotHold):
|
||||
response.Error(c, http.StatusConflict, "deposit_cannot_hold", "当前订单不可暂扣押金")
|
||||
case errors.Is(err, ErrDepositNotHeld):
|
||||
response.Error(c, http.StatusConflict, "deposit_not_held", "该订单押金未处于暂扣状态")
|
||||
case errors.Is(err, ErrDepositHoldAmountEmpty):
|
||||
response.Error(c, http.StatusConflict, "deposit_hold_amount_empty", "暂扣押金尚未形成可归还金额")
|
||||
case IsNotFound(err):
|
||||
response.Error(c, http.StatusNotFound, "not_found", "订单不存在")
|
||||
default:
|
||||
|
||||
@@ -58,6 +58,11 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
||||
SettlementStatus: row.SettlementStatus,
|
||||
RefundStatus: row.RefundStatus,
|
||||
RefundAmountCent: row.RefundAmountCent,
|
||||
DepositHoldStatus: row.DepositHoldStatus,
|
||||
DepositHoldAmountCent: row.DepositHoldAmountCent,
|
||||
DepositHoldReason: row.DepositHoldReason,
|
||||
DepositHeldAt: row.DepositHeldAt,
|
||||
DepositHoldReleasedAt: row.DepositHoldReleasedAt,
|
||||
AdminActions: adminActionsForOrder(row.RentalOrder),
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
@@ -67,6 +72,12 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
||||
func (row orderRow) toDTOForUser(userID uint64) OrderDTO {
|
||||
dto := row.toAdminDTO()
|
||||
dto.AdminActions = nil
|
||||
// 押金暂扣属于客服内部处置,不向租客/号主暴露。
|
||||
dto.DepositHoldStatus = ""
|
||||
dto.DepositHoldAmountCent = 0
|
||||
dto.DepositHoldReason = ""
|
||||
dto.DepositHeldAt = nil
|
||||
dto.DepositHoldReleasedAt = nil
|
||||
applyOrderPriceView(&dto, row.RentalOrder, userID)
|
||||
return dto
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestOrderDTOForUserHidesDepositHoldFields(t *testing.T) {
|
||||
now := time.Now()
|
||||
row := orderRow{
|
||||
RentalOrder: model.RentalOrder{
|
||||
ID: 1,
|
||||
OwnerID: 10,
|
||||
RenterID: 20,
|
||||
DepositHoldStatus: model.DepositHoldStatusHeld,
|
||||
DepositHoldAmountCent: 1000,
|
||||
DepositHoldReason: "风控复核",
|
||||
DepositHeldAt: &now,
|
||||
DepositHoldReleasedAt: &now,
|
||||
},
|
||||
}
|
||||
|
||||
dto := row.toDTOForUser(20)
|
||||
if dto.DepositHoldStatus != "" {
|
||||
t.Fatalf("deposit hold status = %q, want empty", dto.DepositHoldStatus)
|
||||
}
|
||||
if dto.DepositHoldAmountCent != 0 {
|
||||
t.Fatalf("deposit hold amount = %d, want 0", dto.DepositHoldAmountCent)
|
||||
}
|
||||
if dto.DepositHoldReason != "" {
|
||||
t.Fatalf("deposit hold reason = %q, want empty", dto.DepositHoldReason)
|
||||
}
|
||||
if dto.DepositHeldAt != nil {
|
||||
t.Fatalf("deposit held at = %v, want nil", dto.DepositHeldAt)
|
||||
}
|
||||
if dto.DepositHoldReleasedAt != nil {
|
||||
t.Fatalf("deposit hold released at = %v, want nil", dto.DepositHoldReleasedAt)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ var (
|
||||
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
|
||||
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
|
||||
ErrPermissionDenied = errors.New("permission denied")
|
||||
ErrDepositCannotHold = errors.New("deposit cannot hold")
|
||||
ErrDepositNotHeld = errors.New("deposit not held")
|
||||
ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty")
|
||||
)
|
||||
|
||||
const internalOrderHours = 24
|
||||
@@ -216,6 +219,26 @@ func (s *Service) AdminResetHandoff(ctx context.Context, adminID uint64, orderID
|
||||
return s.repo.AdminResetHandoff(ctx, adminID, orderID, req, meta)
|
||||
}
|
||||
|
||||
func (s *Service) AdminHoldDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if orderID == 0 || req.Reason == "" {
|
||||
return ErrDepositCannotHold
|
||||
}
|
||||
return s.repo.AdminHoldDeposit(ctx, adminID, orderID, req, meta)
|
||||
}
|
||||
|
||||
func (s *Service) AdminReleaseDeposit(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if orderID == 0 || req.Reason == "" {
|
||||
return ErrDepositCannotHold
|
||||
}
|
||||
return s.repo.AdminReleaseDeposit(ctx, adminID, orderID, req, meta)
|
||||
}
|
||||
|
||||
func (s *Service) AdminRefund(ctx context.Context, orderID uint64) (*RefundStatusDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
|
||||
@@ -543,6 +543,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.GET("/orders/:id/refund-status", requirePerm("order:view"), orderHandler.AdminRefundStatus)
|
||||
adminRoutes.POST("/orders/:id/refund/approve", requirePerm("order:close"), orderHandler.AdminApproveRefund)
|
||||
adminRoutes.POST("/orders/:id/refund/reject", requirePerm("order:close"), orderHandler.AdminRejectRefund)
|
||||
adminRoutes.POST("/orders/:id/deposit-hold", requirePerm("order:deposit_hold"), orderHandler.AdminHoldDeposit)
|
||||
adminRoutes.POST("/orders/:id/deposit-release", requirePerm("order:deposit_hold"), orderHandler.AdminReleaseDeposit)
|
||||
adminRoutes.GET("/orders/refund-pending", requirePerm("order:close"), orderHandler.ListPendingRefund)
|
||||
|
||||
// 管理员线下提号(独立于正常订单流程)
|
||||
|
||||
Reference in New Issue
Block a user