接入订单结账确认流程

This commit is contained in:
yml
2026-05-24 16:52:15 +08:00
parent 1693ee3167
commit 03e5d211dc
13 changed files with 818 additions and 124 deletions
+12 -12
View File
@@ -318,16 +318,16 @@ func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresh
notification.Entry{
UserID: order.RenterID,
Type: "timeout",
Title: "订单已逾期未归还",
Content: "订单已超过预计截止时间,请尽快提交归还说明,避免进入申诉处理。",
Title: "订单已逾期未结账",
Content: "订单已超过预计截止时间,请尽快发起结账,避免进入申诉处理。",
BizType: "order",
BizID: &orderID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "timeout",
Title: "租客逾期未归还",
Content: "租客未在预计截止后及时归还,你可以发起申诉或等待客服处理。",
Title: "租客逾期未结账",
Content: "租客未在预计截止后及时发起结账,你可以发起申诉或等待客服处理。",
BizType: "order",
BizID: &orderID,
},
@@ -346,7 +346,7 @@ func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresh
func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
var rows []model.RentalOrder
err := j.db.WithContext(ctx).
Where("status = ? AND handoff_status = ? AND updated_at <= ?", "pending_return_confirm", "pending_owner_return_confirm", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)).
Where("status = ? AND handoff_status = ? AND updated_at <= ?", "pending_checkout_confirm", "pending_owner_checkout", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)).
Order("id ASC").
Limit(100).
Find(&rows).Error
@@ -355,28 +355,28 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time
}
count := 0
for _, row := range rows {
if err := j.updateOrder(ctx, row.ID, "order.timeout.owner_return_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) {
if order.Status != "pending_return_confirm" || order.HandoffStatus != "pending_owner_return_confirm" {
if err := j.updateOrder(ctx, row.ID, "order.timeout.owner_checkout_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) {
if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
return "", nil
}
before := snapshot(order)
order.Status = "abnormal"
order.HandoffStatus = "owner_return_confirm_timeout"
order.HandoffStatus = "owner_checkout_confirm_timeout"
orderID := order.ID
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "timeout",
Title: "号主确认归还超时",
Content: "号主未在规定时间内确认归还,订单已进入客服复核状态。",
Title: "号主确认结账超时",
Content: "号主未在规定时间内确认结账,订单已进入客服复核状态。",
BizType: "order",
BizID: &orderID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "timeout",
Title: "确认归还已超时",
Content: "你未在规定时间内确认归还,订单已进入客服复核状态。",
Title: "确认结账已超时",
Content: "你未在规定时间内确认结账,订单已进入客服复核状态。",
BizType: "order",
BizID: &orderID,
},
+34
View File
@@ -0,0 +1,34 @@
package model
import (
"time"
"gorm.io/datatypes"
)
type OrderCheckout struct {
ID uint64 `gorm:"primaryKey" json:"id"`
OrderID uint64 `gorm:"not null;index" json:"order_id"`
InitiatedBy uint64 `gorm:"not null" json:"initiated_by"`
Status string `gorm:"size:32;not null;default:'submitted'" json:"status"`
RentAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"rent_amount"`
DepositAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_amount"`
ConsumableAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"consumable_amount"`
CoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"coin_consumed_m"`
OtherAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"other_amount"`
DepositDeductAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_deduct_amount"`
RenterRefundAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"renter_refund_amount"`
OwnerIncomeAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"owner_income_amount"`
Content string `json:"content"`
EvidenceURLS datatypes.JSON `json:"evidence_urls"`
OwnerAdjustmentReason string `json:"owner_adjustment_reason"`
OwnerAdjustedAt *time.Time `json:"owner_adjusted_at"`
RenterConfirmedAt *time.Time `json:"renter_confirmed_at"`
RenterRejectedAt *time.Time `json:"renter_rejected_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (OrderCheckout) TableName() string {
return "order_checkouts"
}
+41
View File
@@ -27,6 +27,7 @@ type OrderDTO struct {
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
SettlementStatus string `json:"settlement_status"`
Checkout *CheckoutDTO `json:"checkout,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -43,6 +44,23 @@ type SubmitReturnRequest struct {
Content string `json:"content" binding:"required"`
}
type SubmitCheckoutRequest struct {
Content string `json:"content" binding:"required"`
ConsumableAmount float64 `json:"consumable_amount"`
CoinConsumedM float64 `json:"coin_consumed_m"`
OtherAmount float64 `json:"other_amount"`
EvidenceURLS []string `json:"evidence_urls"`
}
type CounterCheckoutRequest struct {
ConsumableAmount float64 `json:"consumable_amount"`
CoinConsumedM float64 `json:"coin_consumed_m"`
OtherAmount float64 `json:"other_amount"`
DepositDeductAmount float64 `json:"deposit_deduct_amount"`
Reason string `json:"reason" binding:"required"`
EvidenceURLS []string `json:"evidence_urls"`
}
type AdminActionRequest struct {
Reason string `json:"reason" binding:"required"`
}
@@ -63,3 +81,26 @@ type HandoffRecordDTO struct {
ConfirmedByOwnerAt *time.Time `json:"confirmed_by_owner_at"`
CreatedAt time.Time `json:"created_at"`
}
type CheckoutDTO struct {
ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"`
InitiatedBy uint64 `json:"initiated_by"`
Status string `json:"status"`
RentAmount float64 `json:"rent_amount"`
DepositAmount float64 `json:"deposit_amount"`
ConsumableAmount float64 `json:"consumable_amount"`
CoinConsumedM float64 `json:"coin_consumed_m"`
OtherAmount float64 `json:"other_amount"`
DepositDeductAmount float64 `json:"deposit_deduct_amount"`
RenterRefundAmount float64 `json:"renter_refund_amount"`
OwnerIncomeAmount float64 `json:"owner_income_amount"`
Content string `json:"content"`
EvidenceURLS []string `json:"evidence_urls"`
OwnerAdjustmentReason string `json:"owner_adjustment_reason"`
OwnerAdjustedAt *time.Time `json:"owner_adjusted_at"`
RenterConfirmedAt *time.Time `json:"renter_confirmed_at"`
RenterRejectedAt *time.Time `json:"renter_rejected_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+88
View File
@@ -267,6 +267,86 @@ func (h *Handler) ConfirmReturn(c *gin.Context) {
response.OK(c, gin.H{"completed": true})
}
func (h *Handler) SubmitCheckout(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req SubmitCheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "结账说明不能为空")
return
}
record, err := h.service.SubmitCheckout(userID, id, req)
if err != nil {
writeOrderError(c, err)
return
}
response.Created(c, record)
}
func (h *Handler) ConfirmCheckout(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
if err := h.service.ConfirmCheckout(userID, id); err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"completed": true})
}
func (h *Handler) CounterCheckout(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req CounterCheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "结账修正原因不能为空")
return
}
checkout, err := h.service.CounterCheckout(userID, id, req)
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, checkout)
}
func (h *Handler) AcceptCheckout(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
if err := h.service.AcceptCheckout(userID, id); err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"completed": true})
}
func currentUserID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextUserID)
if !ok {
@@ -318,6 +398,14 @@ func writeOrderError(c *gin.Context, err error) {
response.Error(c, http.StatusConflict, "order_cannot_return", "当前订单不能归还")
case errors.Is(err, ErrOrderCannotComplete):
response.Error(c, http.StatusConflict, "order_cannot_complete", "当前订单不能完成")
case errors.Is(err, ErrCheckoutCannotSubmit):
response.Error(c, http.StatusConflict, "checkout_cannot_submit", "当前订单不能发起结账")
case errors.Is(err, ErrCheckoutCannotConfirm):
response.Error(c, http.StatusConflict, "checkout_cannot_confirm", "当前结账不能确认")
case errors.Is(err, ErrCheckoutCannotCounter):
response.Error(c, http.StatusConflict, "checkout_cannot_counter", "当前结账不能修改")
case errors.Is(err, ErrInvalidCheckoutAmount):
response.BadRequest(c, "结账金额不符合规则")
case errors.Is(err, ErrPermissionDenied):
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单")
case IsNotFound(err):
+320 -78
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"math"
"strconv"
"time"
@@ -376,6 +377,10 @@ func (r *Repository) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRec
}
func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) {
return r.SubmitCheckout(userID, orderID, SubmitCheckoutRequest{Content: req.Content})
}
func (r *Repository) SubmitCheckout(userID uint64, orderID uint64, req SubmitCheckoutRequest) (*HandoffRecordDTO, error) {
var recordID uint64
err := r.db.Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
@@ -386,28 +391,41 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur
return ErrPermissionDenied
}
if (order.Status != "renting" && order.Status != "overdue") || (order.HandoffStatus != "received" && order.HandoffStatus != "return_overdue") {
return ErrOrderCannotReturn
return ErrCheckoutCannotSubmit
}
hasOpen, err := hasOpenCheckout(tx, order.ID)
if err != nil {
return err
}
if hasOpen {
return ErrCheckoutCannotSubmit
}
now := time.Now()
record := model.HandoffRecord{
OrderID: order.ID,
FromUserID: order.RenterID,
ToUserID: order.OwnerID,
Type: "renter_return",
Type: "renter_checkout",
Content: req.Content,
}
if err := tx.Create(&record).Error; err != nil {
return err
}
order.Status = "pending_return_confirm"
order.HandoffStatus = "pending_owner_return_confirm"
order.RentEndAt = &now
checkout, err := buildCheckout(order, order.RenterID, "submitted", req.Content, req.EvidenceURLS, req.ConsumableAmount, req.CoinConsumedM, req.OtherAmount, 0, false)
if err != nil {
return err
}
if err := tx.Create(&checkout).Error; err != nil {
return err
}
order.Status = "pending_checkout_confirm"
order.HandoffStatus = "pending_owner_checkout"
order.SettlementStatus = "pending"
orderID := order.ID
if err := notification.Append(tx, notification.Entry{
UserID: order.OwnerID,
Type: "return",
Title: "租客已提交归还",
Content: "请检查账号状态,确认无误后完成订单。",
Type: "checkout",
Title: "租客已发起结账",
Content: "请检查账号状态和消耗明细,确认无误后完成结算。",
BizType: "order",
BizID: &orderID,
}); err != nil {
@@ -426,6 +444,10 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur
}
func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error {
return r.ConfirmCheckout(userID, orderID)
}
func (r *Repository) ConfirmCheckout(userID uint64, orderID uint64) error {
return r.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 {
@@ -434,93 +456,120 @@ func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error {
if order.OwnerID != userID {
return ErrPermissionDenied
}
if order.Status != "pending_return_confirm" || order.HandoffStatus != "pending_owner_return_confirm" {
return ErrOrderCannotComplete
if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
return ErrCheckoutCannotConfirm
}
var checkout model.OrderCheckout
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ? AND status = ?", order.ID, "submitted").
Order("id DESC").
First(&checkout).Error; err != nil {
return err
}
now := time.Now()
if err := tx.Model(&model.HandoffRecord{}).
Where("order_id = ? AND type = ?", order.ID, "renter_return").
Where("order_id = ? AND type = ?", order.ID, "renter_checkout").
Update("confirmed_by_owner_at", now).Error; err != nil {
return err
}
var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
checkout.Status = "accepted"
checkout.OwnerAdjustedAt = &now
return r.finalizeCheckout(tx, &order, &checkout, "号主已确认结账,订单完成。")
})
}
func (r *Repository) CounterCheckout(userID uint64, orderID uint64, req CounterCheckoutRequest) (*CheckoutDTO, error) {
var checkoutID uint64
err := r.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
}
var account model.GameAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
if order.OwnerID != userID {
return ErrPermissionDenied
}
if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
return ErrCheckoutCannotCounter
}
var checkout model.OrderCheckout
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ? AND status = ?", order.ID, "submitted").
Order("id DESC").
First(&checkout).Error; err != nil {
return err
}
order.Status = "completed"
order.HandoffStatus = "returned"
order.SettlementStatus = "settled"
order.SettledAt = &now
order.OwnerSettledAt = &now
next, err := buildCheckout(order, checkout.InitiatedBy, "countered", checkout.Content, req.EvidenceURLS, req.ConsumableAmount, req.CoinConsumedM, req.OtherAmount, req.DepositDeductAmount, true)
if err != nil {
return err
}
now := time.Now()
checkout.Status = "countered"
checkout.ConsumableAmount = next.ConsumableAmount
checkout.CoinConsumedM = next.CoinConsumedM
checkout.OtherAmount = next.OtherAmount
checkout.DepositDeductAmount = next.DepositDeductAmount
checkout.RenterRefundAmount = next.RenterRefundAmount
checkout.OwnerIncomeAmount = next.OwnerIncomeAmount
checkout.OwnerAdjustmentReason = req.Reason
checkout.OwnerAdjustedAt = &now
checkout.EvidenceURLS = next.EvidenceURLS
order.Status = "pending_checkout_accept"
order.HandoffStatus = "pending_renter_checkout"
order.SettlementStatus = "pending"
orderID := order.ID
if err := wallet.AppendEntries(tx,
wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "out",
Amount: order.RentAmount + order.DepositAmount,
BalanceType: "frozen",
BizType: "order_settle",
BizNo: order.OrderNo,
Remark: "订单完成释放模拟冻结金额",
},
wallet.Entry{
UserID: order.OwnerID,
OrderID: &orderID,
Direction: "in",
Amount: order.RentAmount,
BalanceType: "available",
BizType: "owner_income",
BizNo: order.OrderNo,
Remark: "订单完成模拟结算订单金额",
},
wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "in",
Amount: order.DepositAmount,
BalanceType: "available",
BizType: "deposit_release",
BizNo: order.OrderNo,
Remark: "订单完成模拟退回押金",
},
); err != nil {
if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "checkout",
Title: "号主已修改结账金额",
Content: "请核对号主修正的消耗和结算金额。同意后订单完成;不同意可发起争议。",
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
}
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "settlement",
Title: "订单已完成",
Content: "号主已确认归还,模拟押金已退回。",
BizType: "order",
BizID: &orderID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "settlement",
Title: "订单已完成",
Content: "订单已完成,模拟订单金额已入账。",
BizType: "order",
BizID: &orderID,
},
); err != nil {
return err
}
listing.Status = "published"
listing.InTransaction = false
account.Status = "published"
if err := tx.Save(&order).Error; err != nil {
return err
}
if err := tx.Save(&listing).Error; err != nil {
if err := tx.Save(&checkout).Error; err != nil {
return err
}
return tx.Save(&account).Error
checkoutID = checkout.ID
return nil
})
if err != nil {
return nil, err
}
checkout, err := r.findCheckout(checkoutID)
if err != nil {
return nil, err
}
dto := toCheckoutDTO(*checkout)
return &dto, nil
}
func (r *Repository) AcceptCheckout(userID uint64, orderID uint64) error {
return r.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.RenterID != userID {
return ErrPermissionDenied
}
if order.Status != "pending_checkout_accept" || order.HandoffStatus != "pending_renter_checkout" {
return ErrCheckoutCannotConfirm
}
var checkout model.OrderCheckout
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ? AND status = ?", order.ID, "countered").
Order("id DESC").
First(&checkout).Error; err != nil {
return err
}
now := time.Now()
checkout.Status = "accepted"
checkout.RenterConfirmedAt = &now
return r.finalizeCheckout(tx, &order, &checkout, "租客已确认修正结账,订单完成。")
})
}
@@ -562,6 +611,7 @@ func (r *Repository) FindAdmin(orderID uint64) (*OrderDTO, error) {
return nil, err
}
dto := row.toDTO()
dto.Checkout = r.latestCheckoutDTO(orderID)
return &dto, nil
}
@@ -752,9 +802,95 @@ func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, erro
return nil, err
}
dto := row.toDTO()
dto.Checkout = r.latestCheckoutDTO(orderID)
return &dto, nil
}
func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, checkout *model.OrderCheckout, renterContent string) error {
var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
return err
}
var account model.GameAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
return err
}
now := time.Now()
order.Status = "completed"
order.HandoffStatus = "returned"
order.SettlementStatus = "settled"
order.SettledAt = &now
order.OwnerSettledAt = &now
listing.Status = "published"
listing.InTransaction = false
account.Status = "published"
orderID := order.ID
if err := wallet.AppendEntries(tx,
wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "out",
Amount: order.RentAmount + order.DepositAmount,
BalanceType: "frozen",
BizType: "order_settle",
BizNo: order.OrderNo,
Remark: "订单结账释放冻结金额",
},
wallet.Entry{
UserID: order.OwnerID,
OrderID: &orderID,
Direction: "in",
Amount: checkout.OwnerIncomeAmount,
BalanceType: "available",
BizType: "owner_income",
BizNo: order.OrderNo,
Remark: "订单结账收入",
},
wallet.Entry{
UserID: order.RenterID,
OrderID: &orderID,
Direction: "in",
Amount: checkout.RenterRefundAmount,
BalanceType: "available",
BizType: "deposit_release",
BizNo: order.OrderNo,
Remark: "订单结账退回押金",
},
); err != nil {
return err
}
if err := notification.Append(tx,
notification.Entry{
UserID: order.RenterID,
Type: "settlement",
Title: "订单已完成",
Content: renterContent,
BizType: "order",
BizID: &orderID,
},
notification.Entry{
UserID: order.OwnerID,
Type: "settlement",
Title: "订单已完成",
Content: "订单已完成,结账金额已入账。",
BizType: "order",
BizID: &orderID,
},
); err != nil {
return err
}
if err := tx.Save(order).Error; err != nil {
return err
}
if err := tx.Save(checkout).Error; err != nil {
return err
}
if err := tx.Save(&listing).Error; err != nil {
return err
}
return tx.Save(&account).Error
}
func (r *Repository) findOrderAssetsForAdminUpdate(tx *gorm.DB, orderID uint64) (*model.RentalOrder, *model.RentalListing, *model.GameAccount, error) {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
@@ -797,6 +933,87 @@ func pendingPaymentTimeoutMinutes(tx *gorm.DB) int {
return value
}
func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, content string, evidenceURLS []string, consumableAmount float64, coinConsumedM float64, otherAmount float64, explicitDeduct float64, useExplicitDeduct bool) (model.OrderCheckout, error) {
if consumableAmount < 0 || coinConsumedM < 0 || otherAmount < 0 || explicitDeduct < 0 {
return model.OrderCheckout{}, ErrInvalidCheckoutAmount
}
deductAmount := consumableAmount + otherAmount
if useExplicitDeduct {
deductAmount = explicitDeduct
}
if deductAmount > order.DepositAmount {
return model.OrderCheckout{}, ErrInvalidCheckoutAmount
}
renterRefund := order.DepositAmount - deductAmount
evidence, err := marshalStringList(evidenceURLS)
if err != nil {
return model.OrderCheckout{}, err
}
return model.OrderCheckout{
OrderID: order.ID,
InitiatedBy: initiatedBy,
Status: status,
RentAmount: order.RentAmount,
DepositAmount: order.DepositAmount,
ConsumableAmount: roundMoney(consumableAmount),
CoinConsumedM: roundMoney(coinConsumedM),
OtherAmount: roundMoney(otherAmount),
DepositDeductAmount: roundMoney(deductAmount),
RenterRefundAmount: roundMoney(renterRefund),
OwnerIncomeAmount: roundMoney(order.RentAmount + deductAmount),
Content: content,
EvidenceURLS: evidence,
}, nil
}
func marshalStringList(items []string) (datatypes.JSON, error) {
if items == nil {
items = []string{}
}
raw, err := json.Marshal(items)
return datatypes.JSON(raw), err
}
func decodeStringList(raw datatypes.JSON) []string {
if len(raw) == 0 {
return []string{}
}
var items []string
if err := json.Unmarshal(raw, &items); err != nil {
return []string{}
}
return items
}
func roundMoney(value float64) float64 {
return math.Round(value*100) / 100
}
func hasOpenCheckout(tx *gorm.DB, orderID uint64) (bool, error) {
var count int64
err := tx.Model(&model.OrderCheckout{}).
Where("order_id = ? AND status IN ?", orderID, []string{"submitted", "countered", "accepted", "disputed"}).
Count(&count).Error
return count > 0, err
}
func (r *Repository) latestCheckoutDTO(orderID uint64) *CheckoutDTO {
var checkout model.OrderCheckout
if err := r.db.Where("order_id = ?", orderID).Order("id DESC").First(&checkout).Error; err != nil {
return nil
}
dto := toCheckoutDTO(checkout)
return &dto
}
func (r *Repository) findCheckout(id uint64) (*model.OrderCheckout, error) {
var checkout model.OrderCheckout
if err := r.db.First(&checkout, id).Error; err != nil {
return nil, err
}
return &checkout, nil
}
func (r *Repository) baseQuery() *gorm.DB {
return r.db.Table("rental_orders AS o").
Select("o.*, a.title, a.server_region, a.login_platform").
@@ -861,6 +1078,31 @@ func toHandoffDTO(record model.HandoffRecord) HandoffRecordDTO {
}
}
func toCheckoutDTO(checkout model.OrderCheckout) CheckoutDTO {
return CheckoutDTO{
ID: checkout.ID,
OrderID: checkout.OrderID,
InitiatedBy: checkout.InitiatedBy,
Status: checkout.Status,
RentAmount: checkout.RentAmount,
DepositAmount: checkout.DepositAmount,
ConsumableAmount: checkout.ConsumableAmount,
CoinConsumedM: checkout.CoinConsumedM,
OtherAmount: checkout.OtherAmount,
DepositDeductAmount: checkout.DepositDeductAmount,
RenterRefundAmount: checkout.RenterRefundAmount,
OwnerIncomeAmount: checkout.OwnerIncomeAmount,
Content: checkout.Content,
EvidenceURLS: decodeStringList(checkout.EvidenceURLS),
OwnerAdjustmentReason: checkout.OwnerAdjustmentReason,
OwnerAdjustedAt: checkout.OwnerAdjustedAt,
RenterConfirmedAt: checkout.RenterConfirmedAt,
RenterRejectedAt: checkout.RenterRejectedAt,
CreatedAt: checkout.CreatedAt,
UpdatedAt: checkout.UpdatedAt,
}
}
func makeAccountSnapshot(account model.GameAccount) (datatypes.JSON, error) {
payload := map[string]any{
"account_id": account.ID,
+38
View File
@@ -14,6 +14,10 @@ var (
ErrOrderCannotReceive = errors.New("order cannot receive")
ErrOrderCannotReturn = errors.New("order cannot return")
ErrOrderCannotComplete = errors.New("order cannot complete")
ErrCheckoutCannotSubmit = errors.New("checkout cannot submit")
ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm")
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
ErrPermissionDenied = errors.New("permission denied")
)
@@ -88,6 +92,16 @@ func (s *Service) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRe
return s.repo.SubmitReturn(userID, orderID, req)
}
func (s *Service) SubmitCheckout(userID uint64, orderID uint64, req SubmitCheckoutRequest) (*HandoffRecordDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if orderID == 0 || req.Content == "" {
return nil, ErrCheckoutCannotSubmit
}
return s.repo.SubmitCheckout(userID, orderID, req)
}
func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
@@ -95,6 +109,30 @@ func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error {
return s.repo.ConfirmReturn(userID, orderID)
}
func (s *Service) ConfirmCheckout(userID uint64, orderID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.ConfirmCheckout(userID, orderID)
}
func (s *Service) CounterCheckout(userID uint64, orderID uint64, req CounterCheckoutRequest) (*CheckoutDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if orderID == 0 || req.Reason == "" {
return nil, ErrCheckoutCannotCounter
}
return s.repo.CounterCheckout(userID, orderID, req)
}
func (s *Service) AcceptCheckout(userID uint64, orderID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.AcceptCheckout(userID, orderID)
}
func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
+4
View File
@@ -173,6 +173,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive)
orderRoutes.POST("/:id/return", orderHandler.SubmitReturn)
orderRoutes.POST("/:id/confirm-return", orderHandler.ConfirmReturn)
orderRoutes.POST("/:id/checkout", orderHandler.SubmitCheckout)
orderRoutes.POST("/:id/checkout/confirm", orderHandler.ConfirmCheckout)
orderRoutes.POST("/:id/checkout/counter", orderHandler.CounterCheckout)
orderRoutes.POST("/:id/checkout/accept", orderHandler.AcceptCheckout)
orderRoutes.POST("/:id/dispute", disputeHandler.Create)
}