按实际纯币金额计算租客优惠与积分
This commit is contained in:
@@ -27,9 +27,16 @@ type RentalOrder struct {
|
||||
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
RentOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
RentDiscountAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
PureCoinOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
ExtraItemOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
ActualCoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"actual_coin_consumed_m"`
|
||||
ActualPureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
ActualPureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
RenterGrowthLevel string `gorm:"size:32;not null;default:'normal'" json:"renter_growth_level"`
|
||||
RenterGrowthLevelName string `gorm:"size:32;not null;default:'普通'" json:"renter_growth_level_name"`
|
||||
RenterDiscountBps int `gorm:"not null;default:10000" json:"renter_discount_bps"`
|
||||
GrowthPointsBasisCent int64 `gorm:"not null;default:0" json:"growth_points_basis_cent"`
|
||||
GrowthPointsPerYuan int64 `gorm:"not null;default:0" json:"growth_points_per_yuan"`
|
||||
GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"`
|
||||
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"`
|
||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||
|
||||
@@ -18,6 +18,9 @@ type OrderCheckout struct {
|
||||
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
PureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
PureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
PureCoinPayableCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
ConsumableAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
CoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"coin_consumed_m"`
|
||||
OtherAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
|
||||
@@ -8,6 +8,8 @@ type RenterGrowthLedger struct {
|
||||
UserID uint64 `gorm:"not null;index" json:"user_id"`
|
||||
OrderID *uint64 `gorm:"index;uniqueIndex:uk_renter_growth_order_source,priority:1" json:"order_id,omitempty"`
|
||||
Points int64 `gorm:"not null;default:0" json:"points"`
|
||||
BasisAmountCent int64 `gorm:"not null;default:0" json:"basis_amount_cent"`
|
||||
PointsPerYuan int64 `gorm:"not null;default:0" json:"points_per_yuan"`
|
||||
BeforePoints int64 `gorm:"not null;default:0" json:"before_points"`
|
||||
AfterPoints int64 `gorm:"not null;default:0" json:"after_points"`
|
||||
BeforeLevel string `gorm:"size:32;not null;default:'normal'" json:"before_level"`
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/adminnotification"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
ordermodule "hfb_sys/backend/internal/modules/order"
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
"hfb_sys/backend/internal/modules/wallet"
|
||||
"hfb_sys/backend/pkg/money"
|
||||
@@ -112,7 +113,23 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
return err
|
||||
}
|
||||
if order.Status == "completed" {
|
||||
if err := rentergrowth.AwardOrderCompleted(tx, &order, arbitrationActualRentCent(order, settlement), rentergrowth.SourceOrderCompleted); err != nil {
|
||||
coinConsumedM, linkedCheckout, err := arbitrationCoinConsumedM(tx, row, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pureCoin := ordermodule.CalculateActualPureCoinAmounts(order, coinConsumedM)
|
||||
order.ActualCoinConsumedM = pureCoin.ConsumedM
|
||||
order.ActualPureCoinAmountCent = pureCoin.AmountCent
|
||||
order.ActualPureCoinDiscountCent = pureCoin.DiscountCent
|
||||
if linkedCheckout != nil {
|
||||
linkedCheckout.PureCoinAmountCent = pureCoin.AmountCent
|
||||
linkedCheckout.PureCoinDiscountCent = pureCoin.DiscountCent
|
||||
linkedCheckout.PureCoinPayableCent = pureCoin.PayableCent
|
||||
if err := tx.Save(linkedCheckout).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := rentergrowth.AwardOrderCompleted(tx, &order, pureCoin.AmountCent, rentergrowth.SourceOrderCompleted); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -146,6 +163,8 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
"renter_refund_amount_cent": settlement.RenterRefundAmountCent,
|
||||
"owner_income_amount_cent": settlement.OwnerIncomeAmountCent,
|
||||
"deposit_deduct_amount_cent": settlement.DepositDeductAmountCent,
|
||||
"actual_coin_consumed_m": order.ActualCoinConsumedM,
|
||||
"actual_pure_coin_cent": order.ActualPureCoinAmountCent,
|
||||
"before_order_status": beforeOrderStatus,
|
||||
"after_order_status": order.Status,
|
||||
"before_handoff_status": beforeHandoffStatus,
|
||||
@@ -200,19 +219,22 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func arbitrationActualRentCent(order model.RentalOrder, settlement arbitrationSettlement) int64 {
|
||||
rentRefundCent := settlement.RenterRefundAmountCent - settlement.RenterDepositRefundCent
|
||||
if rentRefundCent < 0 {
|
||||
rentRefundCent = 0
|
||||
func arbitrationCoinConsumedM(tx *gorm.DB, row model.Dispute, req ArbitrateRequest) (float64, *model.OrderCheckout, error) {
|
||||
var checkout *model.OrderCheckout
|
||||
if row.CheckoutID != nil && *row.CheckoutID > 0 {
|
||||
var saved model.OrderCheckout
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&saved, *row.CheckoutID).Error; err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
checkout = &saved
|
||||
}
|
||||
if rentRefundCent > order.RentAmountCent {
|
||||
rentRefundCent = order.RentAmountCent
|
||||
if req.ActualCoinConsumedM != nil {
|
||||
return *req.ActualCoinConsumedM, checkout, nil
|
||||
}
|
||||
actualRentCent := order.RentAmountCent - rentRefundCent
|
||||
if actualRentCent < 0 {
|
||||
return 0
|
||||
if checkout != nil {
|
||||
return checkout.CoinConsumedM, checkout, nil
|
||||
}
|
||||
return actualRentCent
|
||||
return 0, nil, ErrInvalidDispute
|
||||
}
|
||||
|
||||
func isPlatformManagedOrder(order model.RentalOrder) bool {
|
||||
|
||||
@@ -33,6 +33,7 @@ type DisputeDTO struct {
|
||||
PreviousHandoffStatus string `json:"previous_handoff_status"`
|
||||
PreviousSettlementStatus string `json:"previous_settlement_status"`
|
||||
CheckoutID *uint64 `json:"checkout_id"`
|
||||
CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"`
|
||||
PreviousCheckoutStatus string `json:"previous_checkout_status"`
|
||||
ArbitrationResult string `json:"arbitration_result"`
|
||||
ArbitrationRemark string `json:"arbitration_remark"`
|
||||
@@ -56,9 +57,10 @@ type AdminCreateRequest struct {
|
||||
}
|
||||
|
||||
type ArbitrateRequest struct {
|
||||
Result string `json:"result" binding:"required"`
|
||||
Remark string `json:"remark" binding:"required"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
Result string `json:"result" binding:"required"`
|
||||
Remark string `json:"remark" binding:"required"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"`
|
||||
}
|
||||
|
||||
type AdminListQuery struct {
|
||||
|
||||
@@ -10,16 +10,17 @@ import (
|
||||
|
||||
type disputeRow struct {
|
||||
model.Dispute
|
||||
OrderNo string
|
||||
OrderStatus string
|
||||
HandoffStatus string
|
||||
SettlementStatus string
|
||||
ListingNo string
|
||||
Title string
|
||||
OwnerID uint64
|
||||
RenterID uint64
|
||||
OwnerPhone string
|
||||
RenterPhone string
|
||||
OrderNo string
|
||||
OrderStatus string
|
||||
HandoffStatus string
|
||||
SettlementStatus string
|
||||
ListingNo string
|
||||
Title string
|
||||
OwnerID uint64
|
||||
RenterID uint64
|
||||
OwnerPhone string
|
||||
RenterPhone string
|
||||
CheckoutCoinConsumedM *float64
|
||||
}
|
||||
|
||||
func (row disputeRow) toDTO() DisputeDTO {
|
||||
@@ -48,6 +49,7 @@ func (row disputeRow) toDTO() DisputeDTO {
|
||||
PreviousHandoffStatus: row.PreviousHandoffStatus,
|
||||
PreviousSettlementStatus: row.PreviousSettlementStatus,
|
||||
CheckoutID: row.CheckoutID,
|
||||
CheckoutCoinConsumedM: row.CheckoutCoinConsumedM,
|
||||
PreviousCheckoutStatus: row.PreviousCheckoutStatus,
|
||||
ArbitrationResult: row.ArbitrationResult,
|
||||
ArbitrationRemark: row.ArbitrationRemark,
|
||||
|
||||
@@ -92,7 +92,7 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
||||
return r.adminFilterQuery(ctx).
|
||||
Select(`d.*, o.order_no, o.status AS order_status, o.handoff_status, o.settlement_status,
|
||||
o.owner_id, o.renter_id, owner.phone AS owner_phone, renter.phone AS renter_phone,
|
||||
l.listing_no, a.title`)
|
||||
l.listing_no, a.title, c.coin_consumed_m AS checkout_coin_consumed_m`)
|
||||
}
|
||||
|
||||
func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB {
|
||||
@@ -101,7 +101,8 @@ func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB {
|
||||
Joins("JOIN rental_listings AS l ON l.id = o.listing_id").
|
||||
Joins("JOIN game_accounts AS a ON a.id = o.account_id").
|
||||
Joins("JOIN users AS owner ON owner.id = o.owner_id").
|
||||
Joins("JOIN users AS renter ON renter.id = o.renter_id")
|
||||
Joins("JOIN users AS renter ON renter.id = o.renter_id").
|
||||
Joins("LEFT JOIN order_checkouts AS c ON c.id = d.checkout_id")
|
||||
}
|
||||
|
||||
func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package dispute
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -8,9 +10,36 @@ import (
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func disputePureCoinOrder() model.RentalOrder {
|
||||
return model.RentalOrder{
|
||||
Status: "renting",
|
||||
HandoffStatus: "received",
|
||||
SettlementStatus: "unsettled",
|
||||
RentAmountCent: 38037,
|
||||
OwnerRentAmountCent: 35300,
|
||||
DepositAmountCent: 15000,
|
||||
RentOriginalAmountCent: 38300,
|
||||
RentDiscountAmountCent: 263,
|
||||
PureCoinOriginalAmountCent: 26300,
|
||||
ExtraItemOriginalAmountCent: 12000,
|
||||
RenterDiscountBps: 9900,
|
||||
AccountSnapshot: datatypes.JSON([]byte(`{
|
||||
"haf_coin_amount": 100000000,
|
||||
"asset_summary": {
|
||||
"price_breakdown": {
|
||||
"buyer_coin_base_price": 263,
|
||||
"seller_coin_base_price": 233,
|
||||
"consumable_price": 120
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
}
|
||||
}
|
||||
|
||||
func setupDisputeTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := database.NewTestDB()
|
||||
@@ -261,10 +290,12 @@ func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
|
||||
if adminNotificationCount == 0 {
|
||||
t.Fatal("platform managed dispute should notify managed admin")
|
||||
}
|
||||
actualCoinConsumedM := 0.0
|
||||
|
||||
if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{
|
||||
Result: "release_deposit",
|
||||
Remark: "确认租金给卖家,线下结算",
|
||||
Result: "release_deposit",
|
||||
Remark: "确认租金给卖家,线下结算",
|
||||
ActualCoinConsumedM: &actualCoinConsumedM,
|
||||
}, AuditMeta{}); err != nil {
|
||||
t.Fatalf("仲裁失败: %v", err)
|
||||
}
|
||||
@@ -291,6 +322,90 @@ func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) {
|
||||
db := setupDisputeTestDB(t)
|
||||
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
|
||||
return "refunded", nil
|
||||
})})
|
||||
_, renter, order := createDisputeOrderFixture(t, db, disputePureCoinOrder())
|
||||
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
|
||||
Type: "haf_coin_dispute",
|
||||
Description: "核对最终哈夫币消耗",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建申诉失败: %v", err)
|
||||
}
|
||||
|
||||
_, err = repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
|
||||
Result: "release_deposit",
|
||||
Remark: "确认完成订单",
|
||||
}, AuditMeta{})
|
||||
if !errors.Is(err, ErrInvalidDispute) {
|
||||
t.Fatalf("缺少实际消耗 M 的仲裁错误 = %v, want ErrInvalidDispute", err)
|
||||
}
|
||||
|
||||
actualCoinConsumedM := 50.0
|
||||
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
|
||||
Result: "release_deposit",
|
||||
Remark: "确认实际消耗 50M",
|
||||
ActualCoinConsumedM: &actualCoinConsumedM,
|
||||
}, AuditMeta{}); err != nil {
|
||||
t.Fatalf("带实际消耗 M 仲裁失败: %v", err)
|
||||
}
|
||||
var saved model.RentalOrder
|
||||
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||
t.Fatalf("读取订单失败: %v", err)
|
||||
}
|
||||
if saved.ActualCoinConsumedM != 50 || saved.ActualPureCoinAmountCent != 13150 || saved.GrowthPointsAwarded != 131 {
|
||||
t.Fatalf("最终 M/纯币/积分 = %.2f/%d/%d, want 50/13150/131", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.GrowthPointsAwarded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
|
||||
db := setupDisputeTestDB(t)
|
||||
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
|
||||
return "refunded", nil
|
||||
})})
|
||||
inputOrder := disputePureCoinOrder()
|
||||
inputOrder.Status = "pending_checkout_confirm"
|
||||
inputOrder.HandoffStatus = "pending_owner_checkout"
|
||||
inputOrder.SettlementStatus = "pending"
|
||||
_, renter, order := createDisputeOrderFixture(t, db, inputOrder)
|
||||
checkout := model.OrderCheckout{
|
||||
OrderID: order.ID,
|
||||
InitiatedBy: renter.ID,
|
||||
Status: "submitted",
|
||||
CoinConsumedM: 25,
|
||||
}
|
||||
if err := db.Create(&checkout).Error; err != nil {
|
||||
t.Fatalf("创建结账记录失败: %v", err)
|
||||
}
|
||||
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
|
||||
Type: "checkout_amount",
|
||||
Description: "结账金额有争议",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建结账争议失败: %v", err)
|
||||
}
|
||||
if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 {
|
||||
t.Fatalf("关联结账/M = %v/%v, want %d/25", created.CheckoutID, created.CheckoutCoinConsumedM, checkout.ID)
|
||||
}
|
||||
|
||||
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
|
||||
Result: "release_deposit",
|
||||
Remark: "沿用双方结账消耗",
|
||||
}, AuditMeta{}); err != nil {
|
||||
t.Fatalf("仲裁复用结账 M 失败: %v", err)
|
||||
}
|
||||
var saved model.RentalOrder
|
||||
if err := db.First(&saved, order.ID).Error; err != nil {
|
||||
t.Fatalf("读取订单失败: %v", err)
|
||||
}
|
||||
if saved.ActualCoinConsumedM != 25 || saved.ActualPureCoinAmountCent != 6575 || saved.GrowthPointsAwarded != 65 {
|
||||
t.Fatalf("最终 M/纯币/积分 = %.2f/%d/%d, want 25/6575/65", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.GrowthPointsAwarded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArbitrationSettlementSkipsFrozenReleaseWhenNoFrozenBalance(t *testing.T) {
|
||||
order := model.RentalOrder{
|
||||
ID: 11,
|
||||
|
||||
@@ -90,5 +90,8 @@ func (s *Service) Arbitrate(ctx context.Context, adminID uint64, id uint64, req
|
||||
if id == 0 || req.Result == "" || req.Remark == "" {
|
||||
return nil, ErrInvalidDispute
|
||||
}
|
||||
if req.ActualCoinConsumedM != nil && (*req.ActualCoinConsumedM < 0 || *req.ActualCoinConsumedM > 1_000_000_000) {
|
||||
return nil, ErrInvalidDispute
|
||||
}
|
||||
return s.repo.Arbitrate(ctx, adminID, id, req, meta)
|
||||
}
|
||||
|
||||
@@ -255,6 +255,9 @@ func applyCounterCheckoutUpdate(checkout *model.OrderCheckout, next model.OrderC
|
||||
checkout.OwnerRentAmountCent = next.OwnerRentAmountCent
|
||||
checkout.PlatformFeeCent = next.PlatformFeeCent
|
||||
checkout.DepositAmountCent = next.DepositAmountCent
|
||||
checkout.PureCoinAmountCent = next.PureCoinAmountCent
|
||||
checkout.PureCoinDiscountCent = next.PureCoinDiscountCent
|
||||
checkout.PureCoinPayableCent = next.PureCoinPayableCent
|
||||
checkout.ConsumableAmountCent = next.ConsumableAmountCent
|
||||
checkout.CoinConsumedM = next.CoinConsumedM
|
||||
checkout.OtherAmountCent = next.OtherAmountCent
|
||||
|
||||
@@ -40,10 +40,13 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
||||
return nil, err
|
||||
}
|
||||
applyCheckoutSettlement(checkout, settlement)
|
||||
order.ActualCoinConsumedM = settlement.CoinConsumedM
|
||||
order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent
|
||||
order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent
|
||||
if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rentergrowth.AwardOrderCompleted(tx, order, settlement.ActualRentAmountCent, rentergrowth.SourceOrderCompleted); err != nil {
|
||||
if err := rentergrowth.AwardOrderCompleted(tx, order, settlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := saveFinalizedCheckout(tx, order, checkout, listing, account); err != nil {
|
||||
@@ -110,6 +113,9 @@ func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutS
|
||||
checkout.RentAmountCent = settlement.ActualRentAmountCent
|
||||
checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent
|
||||
checkout.PlatformFeeCent = settlement.PlatformFeeCent
|
||||
checkout.PureCoinAmountCent = settlement.PureCoinAmountCent
|
||||
checkout.PureCoinDiscountCent = settlement.PureCoinDiscountCent
|
||||
checkout.PureCoinPayableCent = settlement.PureCoinPayableCent
|
||||
checkout.RenterRefundAmountCent = settlement.RenterRefundCent
|
||||
checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent
|
||||
checkout.ShortfallCent = settlement.ShortfallCent
|
||||
|
||||
@@ -37,9 +37,16 @@ type OrderDTO struct {
|
||||
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
||||
RentOriginalAmountCent int64 `json:"rent_original_amount_cent,omitempty"`
|
||||
RentDiscountAmountCent int64 `json:"rent_discount_amount_cent,omitempty"`
|
||||
PureCoinOriginalAmountCent int64 `json:"pure_coin_original_amount_cent,omitempty"`
|
||||
ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"`
|
||||
ActualCoinConsumedM float64 `json:"actual_coin_consumed_m,omitempty"`
|
||||
ActualPureCoinAmountCent int64 `json:"actual_pure_coin_amount_cent,omitempty"`
|
||||
ActualPureCoinDiscountCent int64 `json:"actual_pure_coin_discount_cent,omitempty"`
|
||||
RenterGrowthLevel string `json:"renter_growth_level,omitempty"`
|
||||
RenterGrowthLevelName string `json:"renter_growth_level_name,omitempty"`
|
||||
RenterDiscountBps int `json:"renter_discount_bps,omitempty"`
|
||||
GrowthPointsBasisCent int64 `json:"growth_points_basis_cent,omitempty"`
|
||||
GrowthPointsPerYuan int64 `json:"growth_points_per_yuan,omitempty"`
|
||||
GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"`
|
||||
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"`
|
||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||
@@ -191,6 +198,9 @@ type CheckoutDTO struct {
|
||||
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
|
||||
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
PureCoinAmountCent int64 `json:"pure_coin_amount_cent"`
|
||||
PureCoinDiscountCent int64 `json:"pure_coin_discount_cent"`
|
||||
PureCoinPayableCent int64 `json:"pure_coin_payable_cent"`
|
||||
ConsumableAmountCent int64 `json:"consumable_amount_cent"`
|
||||
CoinConsumedM float64 `json:"coin_consumed_m"`
|
||||
OtherAmountCent int64 `json:"other_amount_cent"`
|
||||
|
||||
@@ -48,7 +48,7 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(rentOriginalAmountCent, pricing.PlatformFeeCent, growthSnapshot.DiscountBps)
|
||||
rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(pricing.PureCoinAmountCent, pricing.PureCoinPlatformFeeCent, growthSnapshot.DiscountBps)
|
||||
if rentDiscountAmountCent > 0 {
|
||||
pricing.PlatformFeeCent -= rentDiscountAmountCent
|
||||
pricing.RentAmountCent -= rentDiscountAmountCent
|
||||
@@ -59,34 +59,36 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
||||
return err
|
||||
}
|
||||
order := model.RentalOrder{
|
||||
OrderNo: orderNo,
|
||||
ListingID: listing.ID,
|
||||
AccountID: listing.AccountID,
|
||||
OwnerID: listing.OwnerID,
|
||||
RenterID: renterID,
|
||||
EstimatedDurationHours: rentHours,
|
||||
RentAmountCent: pricing.RentAmountCent,
|
||||
OwnerRentAmountCent: pricing.OwnerRentAmountCent,
|
||||
DepositAmountCent: depositWaiver.PaidCent,
|
||||
DepositOriginalAmountCent: depositOriginalAmountCent,
|
||||
DepositWaivedAmountCent: depositWaiver.WaivedCent,
|
||||
DepositFreeLevelQuotaCent: growthSnapshot.LevelDepositFreeQuotaCent,
|
||||
DepositFreeManualQuotaCent: growthSnapshot.ManualDepositFreeQuotaCent,
|
||||
DepositFreeUsedBeforeCent: depositWaiver.UsedBeforeCent,
|
||||
PlatformFeeCent: pricing.PlatformFeeCent,
|
||||
RentOriginalAmountCent: rentOriginalAmountCent,
|
||||
RentDiscountAmountCent: rentDiscountAmountCent,
|
||||
RenterGrowthLevel: growthSnapshot.LevelCode,
|
||||
RenterGrowthLevelName: growthSnapshot.LevelName,
|
||||
RenterDiscountBps: growthSnapshot.DiscountBps,
|
||||
AccountSnapshot: snapshot,
|
||||
Status: orderStatusPendingPayment,
|
||||
HandoffStatus: handoffStatusNone,
|
||||
HandoffMode: listingHandoffMode(listing),
|
||||
SettlementMode: listingSettlementMode(listing),
|
||||
ManagedAdminID: listing.ManagedAdminID,
|
||||
SettlementStatus: settlementStatusUnsettled,
|
||||
OfflineSettlementStatus: offlineSettlementStatusNone,
|
||||
OrderNo: orderNo,
|
||||
ListingID: listing.ID,
|
||||
AccountID: listing.AccountID,
|
||||
OwnerID: listing.OwnerID,
|
||||
RenterID: renterID,
|
||||
EstimatedDurationHours: rentHours,
|
||||
RentAmountCent: pricing.RentAmountCent,
|
||||
OwnerRentAmountCent: pricing.OwnerRentAmountCent,
|
||||
DepositAmountCent: depositWaiver.PaidCent,
|
||||
DepositOriginalAmountCent: depositOriginalAmountCent,
|
||||
DepositWaivedAmountCent: depositWaiver.WaivedCent,
|
||||
DepositFreeLevelQuotaCent: growthSnapshot.LevelDepositFreeQuotaCent,
|
||||
DepositFreeManualQuotaCent: growthSnapshot.ManualDepositFreeQuotaCent,
|
||||
DepositFreeUsedBeforeCent: depositWaiver.UsedBeforeCent,
|
||||
PlatformFeeCent: pricing.PlatformFeeCent,
|
||||
RentOriginalAmountCent: rentOriginalAmountCent,
|
||||
RentDiscountAmountCent: rentDiscountAmountCent,
|
||||
PureCoinOriginalAmountCent: pricing.PureCoinAmountCent,
|
||||
ExtraItemOriginalAmountCent: pricing.ExtraItemAmountCent,
|
||||
RenterGrowthLevel: growthSnapshot.LevelCode,
|
||||
RenterGrowthLevelName: growthSnapshot.LevelName,
|
||||
RenterDiscountBps: growthSnapshot.DiscountBps,
|
||||
AccountSnapshot: snapshot,
|
||||
Status: orderStatusPendingPayment,
|
||||
HandoffStatus: handoffStatusNone,
|
||||
HandoffMode: listingHandoffMode(listing),
|
||||
SettlementMode: listingSettlementMode(listing),
|
||||
ManagedAdminID: listing.ManagedAdminID,
|
||||
SettlementStatus: settlementStatusUnsettled,
|
||||
OfflineSettlementStatus: offlineSettlementStatusNone,
|
||||
}
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -87,9 +87,16 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
||||
PlatformFeeCent: &platformFeeCent,
|
||||
RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder),
|
||||
RentDiscountAmountCent: row.RentDiscountAmountCent,
|
||||
PureCoinOriginalAmountCent: orderPureCoinOriginalAmountCent(row.RentalOrder),
|
||||
ExtraItemOriginalAmountCent: orderExtraItemOriginalAmountCent(row.RentalOrder),
|
||||
ActualCoinConsumedM: row.ActualCoinConsumedM,
|
||||
ActualPureCoinAmountCent: row.ActualPureCoinAmountCent,
|
||||
ActualPureCoinDiscountCent: row.ActualPureCoinDiscountCent,
|
||||
RenterGrowthLevel: effectiveRenterGrowthLevel(row.RentalOrder),
|
||||
RenterGrowthLevelName: effectiveRenterGrowthLevelName(row.RentalOrder),
|
||||
RenterDiscountBps: effectiveRenterDiscountBps(row.RentalOrder),
|
||||
GrowthPointsBasisCent: row.GrowthPointsBasisCent,
|
||||
GrowthPointsPerYuan: row.GrowthPointsPerYuan,
|
||||
GrowthPointsAwarded: row.GrowthPointsAwarded,
|
||||
GrowthPointsAwardedAt: row.GrowthPointsAwardedAt,
|
||||
AccountSnapshot: row.AccountSnapshot,
|
||||
@@ -225,6 +232,9 @@ func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
|
||||
OwnerRentAmountCent: &ownerRentAmountCent,
|
||||
PlatformFeeCent: &platformFeeCent,
|
||||
DepositAmountCent: checkout.DepositAmountCent,
|
||||
PureCoinAmountCent: checkout.PureCoinAmountCent,
|
||||
PureCoinDiscountCent: checkout.PureCoinDiscountCent,
|
||||
PureCoinPayableCent: checkout.PureCoinPayableCent,
|
||||
ConsumableAmountCent: checkout.ConsumableAmountCent,
|
||||
CoinConsumedM: checkout.CoinConsumedM,
|
||||
OtherAmountCent: checkout.OtherAmountCent,
|
||||
@@ -315,11 +325,18 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
|
||||
dto.OwnerRentAmountCent = &ownerAmountCent
|
||||
dto.RentOriginalAmountCent = 0
|
||||
dto.RentDiscountAmountCent = 0
|
||||
dto.PureCoinOriginalAmountCent = 0
|
||||
dto.ExtraItemOriginalAmountCent = 0
|
||||
dto.ActualCoinConsumedM = 0
|
||||
dto.ActualPureCoinAmountCent = 0
|
||||
dto.ActualPureCoinDiscountCent = 0
|
||||
dto.RenterGrowthLevel = ""
|
||||
dto.RenterGrowthLevelName = ""
|
||||
dto.RenterDiscountBps = 0
|
||||
dto.GrowthPointsAwarded = 0
|
||||
dto.GrowthPointsAwardedAt = nil
|
||||
dto.GrowthPointsBasisCent = 0
|
||||
dto.GrowthPointsPerYuan = 0
|
||||
sanitizeOrderSnapshot(&dto.AccountSnapshot, "owner")
|
||||
case userID == order.RenterID:
|
||||
rentAmountCent := order.RentAmountCent
|
||||
@@ -340,11 +357,18 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
|
||||
dto.OwnerRentAmountCent = nil
|
||||
dto.RentOriginalAmountCent = 0
|
||||
dto.RentDiscountAmountCent = 0
|
||||
dto.PureCoinOriginalAmountCent = 0
|
||||
dto.ExtraItemOriginalAmountCent = 0
|
||||
dto.ActualCoinConsumedM = 0
|
||||
dto.ActualPureCoinAmountCent = 0
|
||||
dto.ActualPureCoinDiscountCent = 0
|
||||
dto.RenterGrowthLevel = ""
|
||||
dto.RenterGrowthLevelName = ""
|
||||
dto.RenterDiscountBps = 0
|
||||
dto.GrowthPointsAwarded = 0
|
||||
dto.GrowthPointsAwardedAt = nil
|
||||
dto.GrowthPointsBasisCent = 0
|
||||
dto.GrowthPointsPerYuan = 0
|
||||
sanitizeOrderSnapshot(&dto.AccountSnapshot, "")
|
||||
}
|
||||
}
|
||||
@@ -379,6 +403,9 @@ func applyCheckoutPriceView(dto *CheckoutDTO, order model.RentalOrder, userID ui
|
||||
dto.RentAmountCent = nil
|
||||
dto.OwnerRentAmountCent = &ownerAmountCent
|
||||
dto.OwnerIncomeAmountCent = &ownerIncomeAmountCent
|
||||
dto.PureCoinAmountCent = 0
|
||||
dto.PureCoinDiscountCent = 0
|
||||
dto.PureCoinPayableCent = 0
|
||||
case userID == order.RenterID:
|
||||
dto.PriceRole = "renter"
|
||||
dto.DisplayAmountCent = rentAmountCent
|
||||
@@ -390,6 +417,9 @@ func applyCheckoutPriceView(dto *CheckoutDTO, order model.RentalOrder, userID ui
|
||||
dto.DisplayAmountCent = 0
|
||||
dto.RentAmountCent = nil
|
||||
dto.OwnerRentAmountCent = nil
|
||||
dto.PureCoinAmountCent = 0
|
||||
dto.PureCoinDiscountCent = 0
|
||||
dto.PureCoinPayableCent = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,21 @@ import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
"hfb_sys/backend/pkg/money"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type orderPricing struct {
|
||||
RentAmountCent int64
|
||||
OwnerRentAmountCent int64
|
||||
PlatformFeeCent int64
|
||||
RentAmountCent int64
|
||||
OwnerRentAmountCent int64
|
||||
PlatformFeeCent int64
|
||||
PureCoinAmountCent int64
|
||||
OwnerPureCoinAmountCent int64
|
||||
ExtraItemAmountCent int64
|
||||
OwnerExtraItemAmountCent int64
|
||||
PureCoinPlatformFeeCent int64
|
||||
}
|
||||
|
||||
type checkoutSettlement struct {
|
||||
@@ -27,10 +33,34 @@ type checkoutSettlement struct {
|
||||
RenterRefundCent int64
|
||||
PlatformFeeCent int64
|
||||
ActualRentAmountCent int64
|
||||
PureCoinAmountCent int64
|
||||
PureCoinDiscountCent int64
|
||||
PureCoinPayableCent int64
|
||||
ExtraItemAmountCent int64
|
||||
CoinConsumedM float64
|
||||
OvershootAmountCent int64
|
||||
ShortfallCent int64
|
||||
}
|
||||
|
||||
// ActualPureCoinAmounts 是跨正常结账与仲裁共用的最终纯币计价结果。
|
||||
type ActualPureCoinAmounts struct {
|
||||
AmountCent int64
|
||||
DiscountCent int64
|
||||
PayableCent int64
|
||||
ConsumedM float64
|
||||
}
|
||||
|
||||
// CalculateActualPureCoinAmounts 按订单价格快照和实际消耗 M 计算纯币原价及等级优惠。
|
||||
func CalculateActualPureCoinAmounts(order model.RentalOrder, coinConsumedM float64) ActualPureCoinAmounts {
|
||||
settlement := calculateCheckoutSettlement(order, 0, coinConsumedM, 0)
|
||||
return ActualPureCoinAmounts{
|
||||
AmountCent: settlement.PureCoinAmountCent,
|
||||
DiscountCent: settlement.PureCoinDiscountCent,
|
||||
PayableCent: settlement.PureCoinPayableCent,
|
||||
ConsumedM: settlement.CoinConsumedM,
|
||||
}
|
||||
}
|
||||
|
||||
func orderDurationHours(order model.RentalOrder) int {
|
||||
if order.EstimatedDurationHours > 0 {
|
||||
return order.EstimatedDurationHours
|
||||
@@ -85,10 +115,34 @@ func buildOrderPricing(listing model.RentalListing, account model.GameAccount) o
|
||||
if platformFeeCent < 0 {
|
||||
platformFeeCent = 0
|
||||
}
|
||||
pureCoinAmountCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "buyer_coin_base_price") * 100))
|
||||
extraItemSnapshotCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "consumable_price") * 100))
|
||||
if pureCoinAmountCent <= 0 {
|
||||
if extraItemSnapshotCent > 0 && extraItemSnapshotCent <= rentAmountCent {
|
||||
pureCoinAmountCent = rentAmountCent - extraItemSnapshotCent
|
||||
} else {
|
||||
pureCoinAmountCent = rentAmountCent
|
||||
}
|
||||
}
|
||||
if pureCoinAmountCent < 0 || pureCoinAmountCent > rentAmountCent {
|
||||
pureCoinAmountCent = rentAmountCent
|
||||
}
|
||||
extraItemAmountCent := maxCent(rentAmountCent-pureCoinAmountCent, 0)
|
||||
ownerPureCoinAmountCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "seller_coin_base_price") * 100))
|
||||
if ownerPureCoinAmountCent <= 0 || ownerPureCoinAmountCent > ownerRentAmountCent {
|
||||
ownerPureCoinAmountCent = maxCent(ownerRentAmountCent-minCent(extraItemAmountCent, ownerRentAmountCent), 0)
|
||||
}
|
||||
ownerExtraItemAmountCent := maxCent(ownerRentAmountCent-ownerPureCoinAmountCent, 0)
|
||||
pureCoinPlatformFeeCent := maxCent(pureCoinAmountCent-ownerPureCoinAmountCent, 0)
|
||||
return orderPricing{
|
||||
RentAmountCent: rentAmountCent,
|
||||
OwnerRentAmountCent: ownerRentAmountCent,
|
||||
PlatformFeeCent: platformFeeCent,
|
||||
RentAmountCent: rentAmountCent,
|
||||
OwnerRentAmountCent: ownerRentAmountCent,
|
||||
PlatformFeeCent: platformFeeCent,
|
||||
PureCoinAmountCent: pureCoinAmountCent,
|
||||
OwnerPureCoinAmountCent: ownerPureCoinAmountCent,
|
||||
ExtraItemAmountCent: extraItemAmountCent,
|
||||
OwnerExtraItemAmountCent: ownerExtraItemAmountCent,
|
||||
PureCoinPlatformFeeCent: pureCoinPlatformFeeCent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +216,9 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c
|
||||
OwnerRentAmountCent: settlement.OwnerRentIncomeCent,
|
||||
PlatformFeeCent: settlement.PlatformFeeCent,
|
||||
DepositAmountCent: order.DepositAmountCent,
|
||||
PureCoinAmountCent: settlement.PureCoinAmountCent,
|
||||
PureCoinDiscountCent: settlement.PureCoinDiscountCent,
|
||||
PureCoinPayableCent: settlement.PureCoinPayableCent,
|
||||
ConsumableAmountCent: consumableAmountCent,
|
||||
CoinConsumedM: roundQuantity(coinConsumedM),
|
||||
OtherAmountCent: otherAmountCent,
|
||||
@@ -185,18 +242,13 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
||||
orderOwnerRentAmountCent := order.OwnerRentAmountCent
|
||||
orderDepositAmountCent := order.DepositAmountCent
|
||||
|
||||
buyerCoinBasePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "buyer_coin_base_price") * 100))
|
||||
if buyerCoinBasePriceCent <= 0 || buyerCoinBasePriceCent > orderRentAmountCent {
|
||||
buyerCoinBasePriceCent = orderRentAmountCent
|
||||
}
|
||||
buyerCoinBasePriceCent := orderPureCoinOriginalAmountCent(order)
|
||||
sellerCoinBasePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "seller_coin_base_price") * 100))
|
||||
if sellerCoinBasePriceCent <= 0 || sellerCoinBasePriceCent > orderOwnerRentAmountCent {
|
||||
sellerCoinBasePriceCent = orderOwnerRentAmountCent
|
||||
}
|
||||
prepaidConsumablePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "consumable_price") * 100))
|
||||
if prepaidConsumablePriceCent <= 0 || prepaidConsumablePriceCent > orderRentAmountCent-buyerCoinBasePriceCent {
|
||||
prepaidConsumablePriceCent = maxCent(orderRentAmountCent-buyerCoinBasePriceCent, 0)
|
||||
ownerExtraItemCent := minCent(orderExtraItemOriginalAmountCent(order), orderOwnerRentAmountCent)
|
||||
sellerCoinBasePriceCent = maxCent(orderOwnerRentAmountCent-ownerExtraItemCent, 0)
|
||||
}
|
||||
prepaidConsumablePriceCent := orderExtraItemOriginalAmountCent(order)
|
||||
prepaidOwnerConsumablePriceCent := maxCent(orderOwnerRentAmountCent-sellerCoinBasePriceCent, 0)
|
||||
|
||||
consumedM := roundQuantity(coinConsumedM)
|
||||
@@ -243,7 +295,10 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
||||
usedOwnerConsumablePriceCent = minCent(usedOwnerConsumablePriceCent, prepaidOwnerConsumablePriceCent)
|
||||
}
|
||||
|
||||
actualBuyerRentCent := usedBuyerCoinPriceCent + usedBuyerConsumablePriceCent
|
||||
pureCoinPlatformFeeCent := maxCent(usedBuyerCoinPriceCent-usedOwnerCoinPriceCent, 0)
|
||||
pureCoinDiscountCent := rentergrowth.CalculateDiscountCent(usedBuyerCoinPriceCent, pureCoinPlatformFeeCent, effectiveRenterDiscountBps(order))
|
||||
pureCoinPayableCent := maxCent(usedBuyerCoinPriceCent-pureCoinDiscountCent, 0)
|
||||
actualBuyerRentCent := pureCoinPayableCent + usedBuyerConsumablePriceCent
|
||||
actualOwnerRentCent := usedOwnerCoinPriceCent + usedOwnerConsumablePriceCent
|
||||
if actualOwnerRentCent > actualBuyerRentCent {
|
||||
actualOwnerRentCent = actualBuyerRentCent
|
||||
@@ -278,11 +333,44 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
||||
RenterRefundCent: rentRefundCent + depositRefundCent,
|
||||
PlatformFeeCent: platformFeeCent,
|
||||
ActualRentAmountCent: actualRentAmountCent,
|
||||
PureCoinAmountCent: usedBuyerCoinPriceCent,
|
||||
PureCoinDiscountCent: pureCoinDiscountCent,
|
||||
PureCoinPayableCent: pureCoinPayableCent,
|
||||
ExtraItemAmountCent: usedBuyerConsumablePriceCent,
|
||||
CoinConsumedM: consumedM,
|
||||
OvershootAmountCent: overshootCent,
|
||||
ShortfallCent: shortfallCent,
|
||||
}
|
||||
}
|
||||
|
||||
func orderPureCoinOriginalAmountCent(order model.RentalOrder) int64 {
|
||||
originalRentCent := effectiveRentOriginalAmountCent(order)
|
||||
if order.PureCoinOriginalAmountCent > 0 {
|
||||
return minCent(order.PureCoinOriginalAmountCent, originalRentCent)
|
||||
}
|
||||
if order.ExtraItemOriginalAmountCent > 0 {
|
||||
return maxCent(originalRentCent-minCent(order.ExtraItemOriginalAmountCent, originalRentCent), 0)
|
||||
}
|
||||
pureCoinCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "buyer_coin_base_price") * 100))
|
||||
if pureCoinCent > 0 && pureCoinCent <= originalRentCent {
|
||||
return pureCoinCent
|
||||
}
|
||||
extraItemCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "consumable_price") * 100))
|
||||
if extraItemCent > 0 && extraItemCent <= originalRentCent {
|
||||
return originalRentCent - extraItemCent
|
||||
}
|
||||
return originalRentCent
|
||||
}
|
||||
|
||||
func orderExtraItemOriginalAmountCent(order model.RentalOrder) int64 {
|
||||
originalRentCent := effectiveRentOriginalAmountCent(order)
|
||||
if order.ExtraItemOriginalAmountCent > 0 {
|
||||
return minCent(order.ExtraItemOriginalAmountCent, originalRentCent)
|
||||
}
|
||||
pureCoinCent := orderPureCoinOriginalAmountCent(order)
|
||||
return maxCent(originalRentCent-pureCoinCent, 0)
|
||||
}
|
||||
|
||||
func readOrderSnapshotCoinM(raw datatypes.JSON) float64 {
|
||||
if len(raw) == 0 {
|
||||
return 0
|
||||
|
||||
@@ -162,7 +162,15 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
|
||||
ServerRegion: "国服",
|
||||
LoginPlatform: "steam",
|
||||
Title: "测试账号",
|
||||
AssetSummary: datatypes.JSON([]byte(`{"price_breakdown":{"seller_total_price":90,"platform_markup_amount":10}}`)),
|
||||
AssetSummary: datatypes.JSON([]byte(`{
|
||||
"price_breakdown": {
|
||||
"buyer_coin_base_price": 263,
|
||||
"seller_coin_base_price": 233,
|
||||
"consumable_price": 120,
|
||||
"seller_total_price": 353,
|
||||
"platform_markup_amount": 30
|
||||
}
|
||||
}`)),
|
||||
}
|
||||
if err := db.Create(&account).Error; err != nil {
|
||||
t.Fatalf("create account failed: %v", err)
|
||||
@@ -170,7 +178,7 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
|
||||
listing := model.RentalListing{
|
||||
AccountID: account.ID,
|
||||
OwnerID: owner.ID,
|
||||
PriceCent: 10000,
|
||||
PriceCent: 38300,
|
||||
DepositAmountCent: 100000,
|
||||
Status: listingStatusPublished,
|
||||
ReviewStatus: listingReviewStatusApproved,
|
||||
@@ -183,11 +191,14 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if dto.RentAmountCent == nil || *dto.RentAmountCent != 9900 {
|
||||
t.Fatalf("rent amount = %v, want 9900", dto.RentAmountCent)
|
||||
if dto.RentAmountCent == nil || *dto.RentAmountCent != 38037 {
|
||||
t.Fatalf("rent amount = %v, want 38037", dto.RentAmountCent)
|
||||
}
|
||||
if dto.RentOriginalAmountCent != 10000 || dto.RentDiscountAmountCent != 100 {
|
||||
t.Fatalf("discount snapshot = original %d discount %d, want 10000/100", dto.RentOriginalAmountCent, dto.RentDiscountAmountCent)
|
||||
if dto.RentOriginalAmountCent != 38300 || dto.RentDiscountAmountCent != 263 {
|
||||
t.Fatalf("discount snapshot = original %d discount %d, want 38300/263", dto.RentOriginalAmountCent, dto.RentDiscountAmountCent)
|
||||
}
|
||||
if dto.PureCoinOriginalAmountCent != 26300 || dto.ExtraItemOriginalAmountCent != 12000 {
|
||||
t.Fatalf("pure coin/extra snapshot = %d/%d, want 26300/12000", dto.PureCoinOriginalAmountCent, dto.ExtraItemOriginalAmountCent)
|
||||
}
|
||||
if dto.RenterGrowthLevel != "platinum" || dto.RenterGrowthLevelName != "铂金" || dto.RenterDiscountBps != 9900 {
|
||||
t.Fatalf("growth snapshot = %s/%s/%d, want platinum/铂金/9900", dto.RenterGrowthLevel, dto.RenterGrowthLevelName, dto.RenterDiscountBps)
|
||||
@@ -196,8 +207,8 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
|
||||
if err := db.First(&saved, dto.ID).Error; err != nil {
|
||||
t.Fatalf("find saved order failed: %v", err)
|
||||
}
|
||||
if saved.OwnerRentAmountCent != 9000 || saved.PlatformFeeCent != 900 {
|
||||
t.Fatalf("pricing = owner %d platform %d, want 9000/900", saved.OwnerRentAmountCent, saved.PlatformFeeCent)
|
||||
if saved.OwnerRentAmountCent != 35300 || saved.PlatformFeeCent != 2737 {
|
||||
t.Fatalf("pricing = owner %d platform %d, want 35300/2737", saved.OwnerRentAmountCent, saved.PlatformFeeCent)
|
||||
}
|
||||
if saved.DepositAmountCent != 30000 || saved.DepositWaivedAmountCent != 70000 {
|
||||
t.Fatalf("deposit = paid %d waived %d, want 30000/70000", saved.DepositAmountCent, saved.DepositWaivedAmountCent)
|
||||
|
||||
@@ -5,11 +5,116 @@ import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
func pureCoinDiscountOrder() model.RentalOrder {
|
||||
return model.RentalOrder{
|
||||
RentAmountCent: 38037,
|
||||
OwnerRentAmountCent: 35300,
|
||||
DepositAmountCent: 15000,
|
||||
RentOriginalAmountCent: 38300,
|
||||
RentDiscountAmountCent: 263,
|
||||
PureCoinOriginalAmountCent: 26300,
|
||||
ExtraItemOriginalAmountCent: 12000,
|
||||
RenterDiscountBps: 9900,
|
||||
AccountSnapshot: datatypes.JSON([]byte(`{
|
||||
"haf_coin_amount": 100000000,
|
||||
"asset_summary": {
|
||||
"price_breakdown": {
|
||||
"buyer_coin_base_price": 263,
|
||||
"seller_coin_base_price": 233,
|
||||
"consumable_price": 120
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateCheckoutSettlementUsesOnlyActualPureCoinForDiscount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
consumableAmountCent int64
|
||||
coinConsumedM float64
|
||||
wantPureCoinCent int64
|
||||
wantDiscountCent int64
|
||||
wantActualRentCent int64
|
||||
wantOvershootCent int64
|
||||
}{
|
||||
{
|
||||
name: "全量纯币和额外物品",
|
||||
consumableAmountCent: 12000,
|
||||
coinConsumedM: 100,
|
||||
wantPureCoinCent: 26300,
|
||||
wantDiscountCent: 263,
|
||||
wantActualRentCent: 38037,
|
||||
wantOvershootCent: 0,
|
||||
},
|
||||
{
|
||||
name: "部分纯币和全量额外物品",
|
||||
consumableAmountCent: 12000,
|
||||
coinConsumedM: 50,
|
||||
wantPureCoinCent: 13150,
|
||||
wantDiscountCent: 132,
|
||||
wantActualRentCent: 25018,
|
||||
wantOvershootCent: 0,
|
||||
},
|
||||
{
|
||||
name: "只使用额外物品",
|
||||
consumableAmountCent: 12000,
|
||||
coinConsumedM: 0,
|
||||
wantPureCoinCent: 0,
|
||||
wantDiscountCent: 0,
|
||||
wantActualRentCent: 12000,
|
||||
wantOvershootCent: 0,
|
||||
},
|
||||
{
|
||||
name: "纯币打超仍参与优惠",
|
||||
consumableAmountCent: 12000,
|
||||
coinConsumedM: 110,
|
||||
wantPureCoinCent: 28930,
|
||||
wantDiscountCent: 290,
|
||||
wantActualRentCent: 40640,
|
||||
wantOvershootCent: 2603,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
settlement := calculateCheckoutSettlement(pureCoinDiscountOrder(), test.consumableAmountCent, test.coinConsumedM, 0)
|
||||
if settlement.PureCoinAmountCent != test.wantPureCoinCent || settlement.PureCoinDiscountCent != test.wantDiscountCent {
|
||||
t.Fatalf("纯币原价/优惠 = %d/%d, want %d/%d", settlement.PureCoinAmountCent, settlement.PureCoinDiscountCent, test.wantPureCoinCent, test.wantDiscountCent)
|
||||
}
|
||||
if settlement.ActualRentAmountCent != test.wantActualRentCent || settlement.OvershootAmountCent != test.wantOvershootCent {
|
||||
t.Fatalf("实际租金/打超 = %d/%d, want %d/%d", settlement.ActualRentAmountCent, settlement.OvershootAmountCent, test.wantActualRentCent, test.wantOvershootCent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOrderPricingDoesNotDiscountAllExtraItemOrder(t *testing.T) {
|
||||
pricing := buildOrderPricing(
|
||||
model.RentalListing{PriceCent: 12000},
|
||||
model.GameAccount{AssetSummary: datatypes.JSON([]byte(`{
|
||||
"price_breakdown": {
|
||||
"buyer_coin_base_price": 0,
|
||||
"seller_coin_base_price": 0,
|
||||
"consumable_price": 120,
|
||||
"seller_total_price": 120
|
||||
}
|
||||
}`))},
|
||||
)
|
||||
if pricing.PureCoinAmountCent != 0 || pricing.ExtraItemAmountCent != 12000 || pricing.PureCoinPlatformFeeCent != 0 {
|
||||
t.Fatalf("全额外物品订单纯币/额外物品/纯币差价 = %d/%d/%d, want 0/12000/0", pricing.PureCoinAmountCent, pricing.ExtraItemAmountCent, pricing.PureCoinPlatformFeeCent)
|
||||
}
|
||||
if discount := rentergrowth.CalculateDiscountCent(pricing.PureCoinAmountCent, pricing.PureCoinPlatformFeeCent, 9900); discount != 0 {
|
||||
t.Fatalf("全额外物品订单优惠 = %d, want 0", discount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateCheckoutSettlementRefundsUnusedRent(t *testing.T) {
|
||||
order := model.RentalOrder{
|
||||
RentAmountCent: 38300,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/datatypes"
|
||||
@@ -273,7 +274,7 @@ func (r *Repository) prepareRefundOrder(ctx context.Context, originalPayment mod
|
||||
}
|
||||
func (r *Repository) syncRefundPayment(ctx context.Context, payment *model.PaymentOrder, queryTerminal bool) (*model.PaymentOrder, error) {
|
||||
if payment.Status == "refunded" && !queryTerminal {
|
||||
if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent); err != nil {
|
||||
if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payment, nil
|
||||
@@ -290,7 +291,7 @@ func (r *Repository) syncRefundPayment(ctx context.Context, payment *model.Payme
|
||||
}
|
||||
if runtimeConfig.isMockMode() {
|
||||
if payment.Status == "refunded" {
|
||||
if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent); err != nil {
|
||||
if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -371,7 +372,7 @@ func (r *Repository) applyRefundChannelStatus(ctx context.Context, payment *mode
|
||||
}
|
||||
switch status {
|
||||
case "refunded":
|
||||
return r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent)
|
||||
return r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType)
|
||||
case "failed":
|
||||
return r.markOrderRefundFailed(ctx, payment.OrderID, payment.AmountCent)
|
||||
default:
|
||||
@@ -394,13 +395,23 @@ func refundQueryRequest(payment model.PaymentOrder, originalPayment model.Paymen
|
||||
ProviderRefundID: payment.ProviderOrderID,
|
||||
}
|
||||
}
|
||||
func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64, refundAmountCent int64) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{
|
||||
"refund_status": "refunded",
|
||||
"refund_amount_cent": refundAmountCent,
|
||||
"refunded_at": now,
|
||||
}).Error
|
||||
func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string) 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
|
||||
}
|
||||
now := time.Now()
|
||||
order.RefundStatus = "refunded"
|
||||
order.RefundAmountCent = refundAmountCent
|
||||
order.RefundedAt = &now
|
||||
if bizType == "admin_refund" && order.Status == "completed" && refundAmountCent >= order.RentAmountCent+order.DepositAmountCent {
|
||||
if err := rentergrowth.RevokeOrderCompleted(tx, &order); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Save(&order).Error
|
||||
})
|
||||
}
|
||||
func (r *Repository) markOrderRefunding(ctx context.Context, orderID uint64, refundAmountCent int64) error {
|
||||
return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
ordermodule "hfb_sys/backend/internal/modules/order"
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -388,6 +389,55 @@ func TestRefundAmountMustNotExceedOriginal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessfulAdminFullRefundRevokesCompletedOrderGrowthPoints(t *testing.T) {
|
||||
db := setupPaymentTestDB(t)
|
||||
repo := NewRepository(db, nil, nil)
|
||||
renter := model.User{Phone: "13900001991", RenterGrowthLevel: "normal"}
|
||||
if err := db.Create(&renter).Error; err != nil {
|
||||
t.Fatalf("create renter failed: %v", err)
|
||||
}
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD202607260991",
|
||||
RenterID: renter.ID,
|
||||
RentAmountCent: 1000,
|
||||
Status: "completed",
|
||||
GrowthPointsBasisCent: 1000,
|
||||
GrowthPointsPerYuan: 1,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("create order failed: %v", err)
|
||||
}
|
||||
if err := rentergrowth.AwardOrderCompleted(db, &order, 1000, rentergrowth.SourceOrderCompleted); err != nil {
|
||||
t.Fatalf("award growth points failed: %v", err)
|
||||
}
|
||||
if err := db.Save(&order).Error; err != nil {
|
||||
t.Fatalf("save awarded order failed: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.updateOrderRefundStatus(t.Context(), order.ID, 1000, "admin_refund"); err != nil {
|
||||
t.Fatalf("update admin refund status failed: %v", err)
|
||||
}
|
||||
if err := repo.updateOrderRefundStatus(t.Context(), order.ID, 1000, "admin_refund"); err != nil {
|
||||
t.Fatalf("repeat admin refund status failed: %v", err)
|
||||
}
|
||||
var savedUser model.User
|
||||
if err := db.First(&savedUser, renter.ID).Error; err != nil {
|
||||
t.Fatalf("find renter failed: %v", err)
|
||||
}
|
||||
if savedUser.RenterGrowthPoints != 0 || savedUser.RenterGrowthLevel != rentergrowth.DefaultLevelCode {
|
||||
t.Fatalf("growth after refund = %d/%s, want 0/%s", savedUser.RenterGrowthPoints, savedUser.RenterGrowthLevel, rentergrowth.DefaultLevelCode)
|
||||
}
|
||||
var revokeCount int64
|
||||
if err := db.Model(&model.RenterGrowthLedger{}).
|
||||
Where("order_id = ? AND source = ?", order.ID, rentergrowth.SourceOrderRefunded).
|
||||
Count(&revokeCount).Error; err != nil {
|
||||
t.Fatalf("count revoke ledger failed: %v", err)
|
||||
}
|
||||
if revokeCount != 1 {
|
||||
t.Fatalf("revoke ledger count = %d, want 1", revokeCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentNotifyResultStructure 测试支付回调结果结构
|
||||
func TestNotifyResultHasRequiredFields(t *testing.T) {
|
||||
result := NotifyResult{
|
||||
|
||||
@@ -26,6 +26,7 @@ const (
|
||||
MaxLevelPoints = 1_000_000_000
|
||||
MaxLevelQuotaCent = 100_000_000
|
||||
SourceOrderCompleted = "order_completed"
|
||||
SourceOrderRefunded = "order_refunded"
|
||||
SourceAdminAdjustment = "admin_adjustment"
|
||||
)
|
||||
|
||||
@@ -243,7 +244,7 @@ func CalculateDiscountCent(originalRentCent int64, platformFeeCent int64, discou
|
||||
return discountCent
|
||||
}
|
||||
|
||||
func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent int64, source string) error {
|
||||
func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualPureCoinCent int64, source string) error {
|
||||
if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -271,10 +272,12 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
order.GrowthPointsBasisCent = maxInt64(actualPureCoinCent, 0)
|
||||
order.GrowthPointsPerYuan = cfg.PointsPerYuan
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
points := PointsForRent(actualRentCent, cfg.PointsPerYuan)
|
||||
points := PointsForPureCoin(actualPureCoinCent, cfg.PointsPerYuan)
|
||||
if points <= 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -293,16 +296,18 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i
|
||||
}
|
||||
now := time.Now()
|
||||
ledger := model.RenterGrowthLedger{
|
||||
UserID: order.RenterID,
|
||||
OrderID: &order.ID,
|
||||
Points: points,
|
||||
BeforePoints: beforePoints,
|
||||
AfterPoints: afterPoints,
|
||||
BeforeLevel: beforeLevel.Code,
|
||||
AfterLevel: afterLevel.Code,
|
||||
Source: source,
|
||||
Remark: "租号订单完成发放成长积分",
|
||||
CreatedAt: now,
|
||||
UserID: order.RenterID,
|
||||
OrderID: &order.ID,
|
||||
Points: points,
|
||||
BasisAmountCent: actualPureCoinCent,
|
||||
PointsPerYuan: cfg.PointsPerYuan,
|
||||
BeforePoints: beforePoints,
|
||||
AfterPoints: afterPoints,
|
||||
BeforeLevel: beforeLevel.Code,
|
||||
AfterLevel: afterLevel.Code,
|
||||
Source: source,
|
||||
Remark: "租号订单完成发放成长积分",
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&ledger).Error; err != nil {
|
||||
return err
|
||||
@@ -312,14 +317,90 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i
|
||||
return nil
|
||||
}
|
||||
|
||||
func PointsForRent(actualRentCent int64, pointsPerYuan int64) int64 {
|
||||
if actualRentCent <= 0 {
|
||||
// RevokeOrderCompleted 在已完成订单被全额人工退款后扣回该单已发放的成长积分。
|
||||
func RevokeOrderCompleted(tx *gorm.DB, order *model.RentalOrder) error {
|
||||
if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 {
|
||||
return nil
|
||||
}
|
||||
var existing model.RenterGrowthLedger
|
||||
err := tx.Where("order_id = ? AND source = ?", order.ID, SourceOrderRefunded).First(&existing).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
points := order.GrowthPointsAwarded
|
||||
if points <= 0 {
|
||||
var awarded model.RenterGrowthLedger
|
||||
if err := tx.Where("order_id = ? AND source = ?", order.ID, SourceOrderCompleted).First(&awarded).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
points = awarded.Points
|
||||
}
|
||||
if points <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg, err := ConfigForTx(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, order.RenterID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
beforePoints := maxInt64(user.RenterGrowthPoints, 0)
|
||||
revokedPoints := points
|
||||
if revokedPoints > beforePoints {
|
||||
revokedPoints = beforePoints
|
||||
}
|
||||
beforeLevel := LevelForPoints(cfg, beforePoints)
|
||||
afterPoints := beforePoints - revokedPoints
|
||||
afterLevel := LevelForPoints(cfg, afterPoints)
|
||||
if !cfg.Enabled {
|
||||
beforeLevel = LevelRule{Code: DefaultLevelCode, Name: DefaultLevelName}
|
||||
afterLevel = beforeLevel
|
||||
}
|
||||
user.RenterGrowthPoints = afterPoints
|
||||
user.RenterGrowthLevel = afterLevel.Code
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
orderID := order.ID
|
||||
return tx.Create(&model.RenterGrowthLedger{
|
||||
UserID: order.RenterID,
|
||||
OrderID: &orderID,
|
||||
Points: -revokedPoints,
|
||||
BasisAmountCent: order.GrowthPointsBasisCent,
|
||||
PointsPerYuan: order.GrowthPointsPerYuan,
|
||||
BeforePoints: beforePoints,
|
||||
AfterPoints: afterPoints,
|
||||
BeforeLevel: beforeLevel.Code,
|
||||
AfterLevel: afterLevel.Code,
|
||||
Source: SourceOrderRefunded,
|
||||
Remark: "已完成订单全额退款扣回成长积分",
|
||||
CreatedAt: time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func PointsForPureCoin(actualPureCoinCent int64, pointsPerYuan int64) int64 {
|
||||
if actualPureCoinCent <= 0 {
|
||||
return 0
|
||||
}
|
||||
if pointsPerYuan <= 0 {
|
||||
pointsPerYuan = DefaultPointsPerYuan
|
||||
}
|
||||
return (actualRentCent / 100) * pointsPerYuan
|
||||
return (actualPureCoinCent / 100) * pointsPerYuan
|
||||
}
|
||||
|
||||
// PointsForRent 保留旧调用兼容,积分基数现统一解释为实际纯币金额。
|
||||
func PointsForRent(actualPureCoinCent int64, pointsPerYuan int64) int64 {
|
||||
return PointsForPureCoin(actualPureCoinCent, pointsPerYuan)
|
||||
}
|
||||
|
||||
func LevelForPoints(cfg Config, points int64) LevelRule {
|
||||
|
||||
@@ -159,6 +159,9 @@ func TestAwardOrderCompletedUpdatesUserAndIsIdempotent(t *testing.T) {
|
||||
if order.GrowthPointsAwarded != 20 || order.GrowthPointsAwardedAt == nil {
|
||||
t.Fatalf("order awarded = %d at %v, want 20 with time", order.GrowthPointsAwarded, order.GrowthPointsAwardedAt)
|
||||
}
|
||||
if order.GrowthPointsBasisCent != 2000 || order.GrowthPointsPerYuan != 1 {
|
||||
t.Fatalf("order points basis = %d x %d, want 2000 x 1", order.GrowthPointsBasisCent, order.GrowthPointsPerYuan)
|
||||
}
|
||||
var savedUser model.User
|
||||
if err := db.First(&savedUser, user.ID).Error; err != nil {
|
||||
t.Fatalf("find user failed: %v", err)
|
||||
@@ -186,4 +189,72 @@ func TestAwardOrderCompletedUpdatesUserAndIsIdempotent(t *testing.T) {
|
||||
if ledgerCount != 1 {
|
||||
t.Fatalf("ledger count = %d, want 1", ledgerCount)
|
||||
}
|
||||
|
||||
if err := RevokeOrderCompleted(db, &order); err != nil {
|
||||
t.Fatalf("RevokeOrderCompleted() error = %v", err)
|
||||
}
|
||||
if err := RevokeOrderCompleted(db, &order); err != nil {
|
||||
t.Fatalf("second RevokeOrderCompleted() error = %v", err)
|
||||
}
|
||||
if err := db.First(&savedUser, user.ID).Error; err != nil {
|
||||
t.Fatalf("find user after revoke failed: %v", err)
|
||||
}
|
||||
if savedUser.RenterGrowthPoints != 290 || savedUser.RenterGrowthLevel != DefaultLevelCode {
|
||||
t.Fatalf("user growth after revoke = %d/%s, want 290/%s", savedUser.RenterGrowthPoints, savedUser.RenterGrowthLevel, DefaultLevelCode)
|
||||
}
|
||||
if err := db.Model(&model.RenterGrowthLedger{}).Where("order_id = ?", order.ID).Count(&ledgerCount).Error; err != nil {
|
||||
t.Fatalf("count ledger after revoke failed: %v", err)
|
||||
}
|
||||
if ledgerCount != 2 {
|
||||
t.Fatalf("ledger count after revoke = %d, want 2", ledgerCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeOrderCompletedRecordsIdempotencyWhenUserHasNoPoints(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.RentalOrder{},
|
||||
&model.SystemConfig{},
|
||||
&model.RenterGrowthLedger{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate failed: %v", err)
|
||||
}
|
||||
user := model.User{Phone: "13800008889", RenterGrowthLevel: DefaultLevelCode}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("create user failed: %v", err)
|
||||
}
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "RG202607260002",
|
||||
RenterID: user.ID,
|
||||
GrowthPointsAwarded: 20,
|
||||
GrowthPointsBasisCent: 2000,
|
||||
GrowthPointsPerYuan: 1,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("create order failed: %v", err)
|
||||
}
|
||||
if err := RevokeOrderCompleted(db, &order); err != nil {
|
||||
t.Fatalf("RevokeOrderCompleted() error = %v", err)
|
||||
}
|
||||
if err := db.Model(&model.User{}).Where("id = ?", user.ID).Update("renter_growth_points", 100).Error; err != nil {
|
||||
t.Fatalf("add later points failed: %v", err)
|
||||
}
|
||||
if err := RevokeOrderCompleted(db, &order); err != nil {
|
||||
t.Fatalf("repeat RevokeOrderCompleted() error = %v", err)
|
||||
}
|
||||
var saved model.User
|
||||
if err := db.First(&saved, user.ID).Error; err != nil {
|
||||
t.Fatalf("find user failed: %v", err)
|
||||
}
|
||||
if saved.RenterGrowthPoints != 100 {
|
||||
t.Fatalf("later points after repeated callback = %d, want 100", saved.RenterGrowthPoints)
|
||||
}
|
||||
var ledger model.RenterGrowthLedger
|
||||
if err := db.Where("order_id = ? AND source = ?", order.ID, SourceOrderRefunded).First(&ledger).Error; err != nil {
|
||||
t.Fatalf("find revoke ledger failed: %v", err)
|
||||
}
|
||||
if ledger.Points != 0 {
|
||||
t.Fatalf("revoke ledger points = %d, want 0", ledger.Points)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE rental_orders
|
||||
ADD COLUMN pure_coin_original_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '下单时租客纯币原价快照(分)' AFTER rent_discount_amount_cent,
|
||||
ADD COLUMN extra_item_original_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '下单时额外物品原价快照(分)' AFTER pure_coin_original_amount_cent,
|
||||
ADD COLUMN actual_coin_consumed_m DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '最终确认哈夫币消耗(M)' AFTER extra_item_original_amount_cent,
|
||||
ADD COLUMN actual_pure_coin_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '最终实际纯币原价(分)' AFTER actual_coin_consumed_m,
|
||||
ADD COLUMN actual_pure_coin_discount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '最终实际纯币等级优惠(分)' AFTER actual_pure_coin_amount_cent,
|
||||
ADD COLUMN growth_points_basis_cent BIGINT NOT NULL DEFAULT 0 COMMENT '成长积分计算纯币基数(分)' AFTER renter_discount_bps,
|
||||
ADD COLUMN growth_points_per_yuan BIGINT NOT NULL DEFAULT 0 COMMENT '成长积分发放倍率' AFTER growth_points_basis_cent;
|
||||
|
||||
ALTER TABLE order_checkouts
|
||||
ADD COLUMN pure_coin_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '方案实际纯币原价(分)' AFTER deposit_amount_cent,
|
||||
ADD COLUMN pure_coin_discount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '方案实际纯币等级优惠(分)' AFTER pure_coin_amount_cent,
|
||||
ADD COLUMN pure_coin_payable_cent BIGINT NOT NULL DEFAULT 0 COMMENT '方案实际折后纯币金额(分)' AFTER pure_coin_discount_cent;
|
||||
|
||||
ALTER TABLE renter_growth_ledger
|
||||
ADD COLUMN basis_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '积分计算纯币基数(分)' AFTER points,
|
||||
ADD COLUMN points_per_yuan BIGINT NOT NULL DEFAULT 0 COMMENT '积分发放倍率' AFTER basis_amount_cent;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE order_checkouts
|
||||
DROP COLUMN pure_coin_payable_cent,
|
||||
DROP COLUMN pure_coin_discount_cent,
|
||||
DROP COLUMN pure_coin_amount_cent;
|
||||
|
||||
ALTER TABLE renter_growth_ledger
|
||||
DROP COLUMN points_per_yuan,
|
||||
DROP COLUMN basis_amount_cent;
|
||||
|
||||
ALTER TABLE rental_orders
|
||||
DROP COLUMN growth_points_per_yuan,
|
||||
DROP COLUMN growth_points_basis_cent,
|
||||
DROP COLUMN actual_pure_coin_discount_cent,
|
||||
DROP COLUMN actual_pure_coin_amount_cent,
|
||||
DROP COLUMN actual_coin_consumed_m,
|
||||
DROP COLUMN extra_item_original_amount_cent,
|
||||
DROP COLUMN pure_coin_original_amount_cent;
|
||||
Reference in New Issue
Block a user