fix(invitation): 邀请返利改用实际结算利润并修正提号基准
This commit is contained in:
@@ -135,6 +135,11 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
|
||||
if err := distribution.ApplyRefundTx(tx, order.ID, refundAmountCent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if order.Status == "completed" {
|
||||
if actualSettlement == nil {
|
||||
return ErrInvalidDispute
|
||||
@@ -153,11 +158,6 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
)
|
||||
}
|
||||
}
|
||||
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
|
||||
if err := distribution.ApplyRefundTx(tx, order.ID, refundAmountCent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Save(&listing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -369,6 +369,14 @@ func (r *Repository) processOrderInvitationRebate(ctx context.Context, tx *gorm.
|
||||
orderGrossProfitCent := order.RentAmountCent - order.OwnerRentAmountCent
|
||||
if opts.GrossProfitCent != nil {
|
||||
orderGrossProfitCent = *opts.GrossProfitCent
|
||||
} else {
|
||||
// 正常结账、仲裁等终态都已落定实际结账单;未显式传基数时优先按实际平台利润,
|
||||
// 避免再次用订单创建时的预收毛利快照给零消耗结账发返利。
|
||||
var committedCheckout model.OrderCheckout
|
||||
if err := tx.Where("order_id = ? AND status IN ?", order.ID, []string{"accepted", "settled"}).
|
||||
Order("id DESC").First(&committedCheckout).Error; err == nil {
|
||||
orderGrossProfitCent = committedCheckout.PlatformFeeCent
|
||||
}
|
||||
}
|
||||
if orderGrossProfitCent <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -20,6 +20,7 @@ func setupTestDB(t *testing.T) *gorm.DB {
|
||||
&model.InvitationRebateConfig{},
|
||||
&model.InvitationRebateLedger{},
|
||||
&model.RentalOrder{},
|
||||
&model.OrderCheckout{},
|
||||
&model.DistributionSite{},
|
||||
&model.DistributionOrderParty{},
|
||||
&model.WalletAccount{},
|
||||
@@ -262,6 +263,57 @@ func TestProcessOrderInvitationRebateUsesActualProfitBasis(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOrderInvitationRebateFallsBackToCommittedCheckoutProfit(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
inviter := model.User{Phone: "13800000021", RealnameStatus: "verified", Status: "active"}
|
||||
if err := db.Create(&inviter).Error; err != nil {
|
||||
t.Fatalf("创建邀请人失败: %v", err)
|
||||
}
|
||||
seller := model.User{Phone: "13800000022", RealnameStatus: "verified", Status: "active"}
|
||||
if err := db.Create(&seller).Error; err != nil {
|
||||
t.Fatalf("创建卖家失败: %v", err)
|
||||
}
|
||||
verifiedAt := time.Now().AddDate(0, 0, -2).Truncate(time.Second)
|
||||
if err := db.Create(&model.UserInvitation{
|
||||
InviterID: inviter.ID, InviteeID: seller.ID, Status: StatusActive, VerifiedAt: &verifiedAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建邀请关系失败: %v", err)
|
||||
}
|
||||
order := model.RentalOrder{
|
||||
OrderNo: fmt.Sprintf("ORD-COMMITTED-%d", time.Now().UnixNano()),
|
||||
OwnerID: seller.ID,
|
||||
RenterID: 99999,
|
||||
Status: "completed",
|
||||
SettlementStatus: "settled",
|
||||
RentAmountCent: 10000,
|
||||
OwnerRentAmountCent: 8000,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("创建订单失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.OrderCheckout{
|
||||
OrderID: order.ID,
|
||||
Status: "accepted",
|
||||
RentAmountCent: 2000,
|
||||
OwnerRentAmountCent: 1700,
|
||||
PlatformFeeCent: 300,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建已确认结账单失败: %v", err)
|
||||
}
|
||||
|
||||
if err := NewRepository(db).ProcessOrderInvitationRebate(t.Context(), db, order.ID); err != nil {
|
||||
t.Fatalf("返利处理失败: %v", err)
|
||||
}
|
||||
var ledger model.InvitationRebateLedger
|
||||
if err := db.Where("order_id = ? AND trade_role = 'seller'", order.ID).First(&ledger).Error; err != nil {
|
||||
t.Fatalf("查询返利流水失败: %v", err)
|
||||
}
|
||||
if ledger.OrderProfitBasisCent != 300 || ledger.SingleSideBasisCent != 150 || ledger.RebateAmountCent != 90 {
|
||||
t.Fatalf("返利基数/单边/金额 = %d/%d/%d, want 300/150/90",
|
||||
ledger.OrderProfitBasisCent, ledger.SingleSideBasisCent, ledger.RebateAmountCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossSiteDistributionRebate(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
@@ -62,7 +62,13 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
_ = invitation.ProcessOrderInvitationRebateTx(tx, order.ID)
|
||||
// 返利必须按本次实际结算的平台利润计算,不能用订单创建时的预收毛利快照。
|
||||
actualPlatformProfitCent := settlement.PlatformFeeCent
|
||||
_ = invitation.ProcessOrderInvitationRebateWithOptionsTx(tx, order.ID, invitation.OrderRebateOptions{
|
||||
GrossProfitCent: &actualPlatformProfitCent,
|
||||
RateReferenceAt: now,
|
||||
SettledAt: now,
|
||||
})
|
||||
|
||||
return refund, nil
|
||||
}
|
||||
|
||||
@@ -2754,3 +2754,149 @@ func TestHideForOwnerRemovesSellerViewOnly(t *testing.T) {
|
||||
t.Fatalf("隐藏只写时间,订单状态应保持完成,got hidden=%v status=%s", saved.OwnerHiddenAt, saved.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// 邀请返利必须以实际结账落定的平台利润为基数:
|
||||
// 租客申报 0 哈夫币损耗、0 额外物品时,实际平台利润为 0,不得再按订单预收毛利返利。
|
||||
func TestCheckoutInvitationRebateUsesActualPlatformProfit(t *testing.T) {
|
||||
db := setupOrderTestDB(t)
|
||||
if err := database.AutoMigrateIfMissing(db,
|
||||
&model.UserInvitation{},
|
||||
&model.InvitationRebateConfig{},
|
||||
&model.InvitationRebateLedger{},
|
||||
); err != nil {
|
||||
t.Fatalf("迁移邀请返利表失败: %v", err)
|
||||
}
|
||||
repo := NewRepository(db, Dependencies{
|
||||
RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
|
||||
return "refunding", nil
|
||||
}),
|
||||
})
|
||||
snapshot := `{"haf_coin_amount":1000000,"asset_summary":{"price_breakdown":{"buyer_ratio":1,"seller_ratio":1.5,"buyer_coin_base_price":100,"seller_coin_base_price":80}}}`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
coinConsumedM float64
|
||||
wantPlatformCent int64
|
||||
wantBasisCent int64
|
||||
wantSingleCent int64
|
||||
wantRebateCent int64
|
||||
}{
|
||||
{name: "零消耗不产生返利", coinConsumedM: 0, wantPlatformCent: 0},
|
||||
{name: "部分消耗按实际利润", coinConsumedM: 0.1, wantPlatformCent: 333, wantBasisCent: 333, wantSingleCent: 166, wantRebateCent: 100},
|
||||
}
|
||||
|
||||
for index, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
inviter := model.User{
|
||||
Phone: fmt.Sprintf("1391000%04d", index),
|
||||
Nickname: "结算返利邀请人",
|
||||
RealnameStatus: "verified",
|
||||
Status: "active",
|
||||
}
|
||||
owner := model.User{Phone: fmt.Sprintf("1392000%04d", index), Nickname: "结算返利号主", Status: "active"}
|
||||
renter := model.User{Phone: fmt.Sprintf("1393000%04d", index), Nickname: "结算返利租客", Status: "active"}
|
||||
if err := db.Create(&inviter).Error; err != nil {
|
||||
t.Fatalf("创建邀请人失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&owner).Error; err != nil {
|
||||
t.Fatalf("创建号主失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&renter).Error; err != nil {
|
||||
t.Fatalf("创建租客失败: %v", err)
|
||||
}
|
||||
|
||||
verifiedAt := time.Now().AddDate(0, 0, -2).Truncate(time.Second)
|
||||
if err := db.Create(&model.UserInvitation{
|
||||
InviterID: inviter.ID,
|
||||
InviteeID: owner.ID,
|
||||
InviteCode: fmt.Sprintf("actual%02d", index),
|
||||
Status: "active",
|
||||
VerifiedAt: &verifiedAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建邀请关系失败: %v", err)
|
||||
}
|
||||
|
||||
account := model.GameAccount{
|
||||
OwnerID: owner.ID,
|
||||
ServerRegion: "国服",
|
||||
LoginPlatform: "steam",
|
||||
Title: "结算返利测试账号",
|
||||
Status: "rented",
|
||||
}
|
||||
if err := db.Create(&account).Error; err != nil {
|
||||
t.Fatalf("创建账号失败: %v", err)
|
||||
}
|
||||
listing := model.RentalListing{
|
||||
ListingNo: fmt.Sprintf("LST-REBATE-ACTUAL-%02d", index),
|
||||
AccountID: account.ID,
|
||||
OwnerID: owner.ID,
|
||||
PriceCent: 10000,
|
||||
Status: "rented",
|
||||
ReviewStatus: "approved",
|
||||
InTransaction: true,
|
||||
}
|
||||
if err := db.Create(&listing).Error; err != nil {
|
||||
t.Fatalf("创建上架记录失败: %v", err)
|
||||
}
|
||||
order := model.RentalOrder{
|
||||
OrderNo: fmt.Sprintf("ORD-REBATE-ACTUAL-%d-%02d", time.Now().UnixNano(), index),
|
||||
ListingID: listing.ID,
|
||||
AccountID: account.ID,
|
||||
OwnerID: owner.ID,
|
||||
RenterID: renter.ID,
|
||||
Status: orderStatusRenting,
|
||||
HandoffStatus: handoffStatusReceived,
|
||||
EstimatedDurationHours: 24,
|
||||
RentAmountCent: 10000,
|
||||
RentOriginalAmountCent: 10000,
|
||||
OwnerRentAmountCent: 8000,
|
||||
PlatformFeeCent: 2000,
|
||||
PureCoinOriginalAmountCent: 10000,
|
||||
AccountSnapshot: datatypes.JSON([]byte(snapshot)),
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("创建订单失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.SubmitCheckout(t.Context(), renter.ID, order.ID, SubmitCheckoutRequest{
|
||||
Content: "按实际消耗结账",
|
||||
CoinConsumedM: tc.coinConsumedM,
|
||||
}); err != nil {
|
||||
t.Fatalf("提交结账失败: %v", err)
|
||||
}
|
||||
if err := repo.ConfirmCheckout(t.Context(), owner.ID, order.ID); err != nil {
|
||||
t.Fatalf("确认结账失败: %v", err)
|
||||
}
|
||||
|
||||
var checkout model.OrderCheckout
|
||||
if err := db.Where("order_id = ?", order.ID).Order("id DESC").First(&checkout).Error; err != nil {
|
||||
t.Fatalf("读取结账单失败: %v", err)
|
||||
}
|
||||
if checkout.PlatformFeeCent != tc.wantPlatformCent {
|
||||
t.Fatalf("实际平台利润 = %d, want %d", checkout.PlatformFeeCent, tc.wantPlatformCent)
|
||||
}
|
||||
|
||||
var ledgers []model.InvitationRebateLedger
|
||||
if err := db.Where("source = ? AND order_id = ?", "rental_order", order.ID).Find(&ledgers).Error; err != nil {
|
||||
t.Fatalf("读取返利流水失败: %v", err)
|
||||
}
|
||||
if tc.wantRebateCent == 0 {
|
||||
if len(ledgers) != 0 {
|
||||
t.Fatalf("零消耗结账不应发返利,实际 %d 条", len(ledgers))
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(ledgers) != 1 {
|
||||
t.Fatalf("返利流水条数 = %d, want 1", len(ledgers))
|
||||
}
|
||||
ledger := ledgers[0]
|
||||
if ledger.OrderProfitBasisCent != tc.wantBasisCent ||
|
||||
ledger.SingleSideBasisCent != tc.wantSingleCent ||
|
||||
ledger.RebateAmountCent != tc.wantRebateCent {
|
||||
t.Fatalf("返利基数/单边/金额 = %d/%d/%d, want %d/%d/%d",
|
||||
ledger.OrderProfitBasisCent, ledger.SingleSideBasisCent, ledger.RebateAmountCent,
|
||||
tc.wantBasisCent, tc.wantSingleCent, tc.wantRebateCent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,8 +308,9 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete
|
||||
}
|
||||
|
||||
// 邀请返利:线下提号不走正常订单结账流程,必须在这里补发号主(卖家)侧返利,
|
||||
// 否则号主邀请人永远拿不到提号产生的分成。基数按提号利润对半(与租赁订单卖家侧口径一致)。
|
||||
if grossBasisCent := pickup.ProfitAmountCent; grossBasisCent > 0 {
|
||||
// 否则号主邀请人永远拿不到提号产生的分成。
|
||||
// 基准取「网站加价」与「线下利润」的较小值,避免按虚高的单一利润口径多算。
|
||||
if grossBasisCent := pickupRebateGrossBasis(pickup.WebsiteProfitCent, pickup.ProfitAmountCent); grossBasisCent > 0 {
|
||||
sellerSiteID := uint64(0)
|
||||
if listing.DistributionSiteID != nil {
|
||||
sellerSiteID = *listing.DistributionSiteID
|
||||
@@ -1923,6 +1924,20 @@ func maxPickupCent(a, b int64) int64 {
|
||||
return b
|
||||
}
|
||||
|
||||
// pickupRebateGrossBasis 取网站加价与线下利润的较小值,负数按 0 处理。
|
||||
func pickupRebateGrossBasis(websiteProfitCent, offlineProfitCent int64) int64 {
|
||||
if websiteProfitCent < 0 {
|
||||
websiteProfitCent = 0
|
||||
}
|
||||
if offlineProfitCent < 0 {
|
||||
offlineProfitCent = 0
|
||||
}
|
||||
if websiteProfitCent < offlineProfitCent {
|
||||
return websiteProfitCent
|
||||
}
|
||||
return offlineProfitCent
|
||||
}
|
||||
|
||||
func normalizePaging(page, pageSize int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
|
||||
@@ -376,6 +376,22 @@ func TestRepositoryCompletePickupAwardsSellerInvitationRebate(t *testing.T) {
|
||||
}
|
||||
|
||||
listing := seedAvailableListing(t, db, "LST-REBATE-001", "返利提号账号", false)
|
||||
// 网站加价 40 元、线下利润 100 元:返利基准必须取较小的 40 元。
|
||||
priceSummary, err := json.Marshal(map[string]any{
|
||||
"price_breakdown": map[string]any{
|
||||
"seller_coin_base_price": 60,
|
||||
"seller_total_price": 60,
|
||||
"platform_markup_amount": 40,
|
||||
"consumable_price": 0,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("构造价格快照失败: %v", err)
|
||||
}
|
||||
if err := db.Model(&model.GameAccount{}).Where("id = ?", listing.AccountID).
|
||||
Update("asset_summary", datatypes.JSON(priceSummary)).Error; err != nil {
|
||||
t.Fatalf("更新账号价格快照失败: %v", err)
|
||||
}
|
||||
// 距提号完成 2 天 => 首月 60% 档;Truncate 到整秒避免 DATETIME(0) 四舍五入影响天数计算。
|
||||
verifiedAt := time.Now().AddDate(0, 0, -2).Truncate(time.Second)
|
||||
if err := db.Create(&model.UserInvitation{
|
||||
@@ -420,9 +436,9 @@ func TestRepositoryCompletePickupAwardsSellerInvitationRebate(t *testing.T) {
|
||||
if rebate.OrderNo != pickup.PickupNo {
|
||||
t.Fatalf("返利单据号 = %q, want %q", rebate.OrderNo, pickup.PickupNo)
|
||||
}
|
||||
// 基数 = 提号利润 10000 对半 = 5000,首月 60% = 3000
|
||||
assertInt64(t, "提号返利单边基数", rebate.SingleSideBasisCent, 5000)
|
||||
assertInt64(t, "提号返利金额", rebate.RebateAmountCent, 3000)
|
||||
// 基数 = min(网站加价 4000, 线下利润 10000) 对半 = 2000,首月 60% = 1200
|
||||
assertInt64(t, "提号返利单边基数", rebate.SingleSideBasisCent, 2000)
|
||||
assertInt64(t, "提号返利金额", rebate.RebateAmountCent, 1200)
|
||||
|
||||
var wallet model.WalletLedger
|
||||
if err := db.Where("biz_type = ? AND biz_no = ?", "invite_rebate", pickup.PickupNo).First(&wallet).Error; err != nil {
|
||||
@@ -431,7 +447,29 @@ func TestRepositoryCompletePickupAwardsSellerInvitationRebate(t *testing.T) {
|
||||
if wallet.UserID != inviter.ID {
|
||||
t.Fatalf("返利入账用户 = %d, want %d", wallet.UserID, inviter.ID)
|
||||
}
|
||||
assertInt64(t, "提号返利入账金额", wallet.AmountCent, 3000)
|
||||
assertInt64(t, "提号返利入账金额", wallet.AmountCent, 1200)
|
||||
}
|
||||
|
||||
func TestPickupRebateGrossBasisTakesLowerProfit(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
websiteProfit int64
|
||||
offlineProfit int64
|
||||
wantGrossBasis int64
|
||||
}{
|
||||
{name: "网站加价更低", websiteProfit: 4000, offlineProfit: 10000, wantGrossBasis: 4000},
|
||||
{name: "线下利润更低", websiteProfit: 10000, offlineProfit: 4000, wantGrossBasis: 4000},
|
||||
{name: "线下利润为零", websiteProfit: 10000, offlineProfit: 0, wantGrossBasis: 0},
|
||||
{name: "负数归零", websiteProfit: -1, offlineProfit: 4000, wantGrossBasis: 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := pickupRebateGrossBasis(tc.websiteProfit, tc.offlineProfit); got != tc.wantGrossBasis {
|
||||
t.Fatalf("pickupRebateGrossBasis(%d, %d) = %d, want %d",
|
||||
tc.websiteProfit, tc.offlineProfit, got, tc.wantGrossBasis)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryCompletePlatformManagedPickupUsesOfflineSettlement(t *testing.T) {
|
||||
@@ -1145,4 +1183,3 @@ func TestRepositoryUpdateThirdPartyOrderNo(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user