fix(distribution): 修正分销模式判定与结算入账,冻结未确认分成
按买卖站点纯函数判定五种模式,分站卖主站货命中 two_party_platform_receive。 结算批次只收本站 site party,平台分成不再进入分销钱包;生产环境冻结结算入账与站点提现。
This commit is contained in:
@@ -118,13 +118,16 @@ func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch.TotalAmountCent != 750 {
|
||||
t.Fatalf("batch total = %d, want 750", batch.TotalAmountCent)
|
||||
if batch.TotalAmountCent != 350 {
|
||||
t.Fatalf("batch total = %d, want 350 (site share only)", batch.TotalAmountCent)
|
||||
}
|
||||
var batchItems []model.DistributionSettlementItem
|
||||
if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 2 {
|
||||
if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 1 {
|
||||
t.Fatalf("batch items = %d, err=%v", len(batchItems), err)
|
||||
}
|
||||
if batchItems[0].AmountCent != 350 || batchItems[0].PartyID != parties[0].ID {
|
||||
t.Fatalf("batch item should be site party only: %+v", batchItems[0])
|
||||
}
|
||||
// 空批次不允许结算。
|
||||
emptyBatch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start.Add(-48 * time.Hour), PeriodEnd: start.Add(-24 * time.Hour)})
|
||||
if err != nil {
|
||||
@@ -145,6 +148,13 @@ func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
|
||||
if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != ErrBatchAlreadySettled {
|
||||
t.Fatalf("second settle err = %v", err)
|
||||
}
|
||||
walletDTO, err := repo.GetSiteWallet(t.Context(), 0, site.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if walletDTO.AvailableBalanceCent != 350 || walletDTO.TotalSettledCent != 350 {
|
||||
t.Fatalf("site wallet should only receive site share, got %+v", walletDTO)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
|
||||
@@ -349,6 +359,9 @@ func TestOnboardSiteAutoSyncsActiveListings(t *testing.T) {
|
||||
if displays[0].Title != "王者荣耀国服账号" || displays[0].ListingNo != "L20001" {
|
||||
t.Fatalf("unexpected auto-synced display: %+v", displays[0])
|
||||
}
|
||||
if onboardResp.Rule.Status != "draft" || onboardResp.Rule.DistributorReceiveRatio != 0 || onboardResp.Rule.PlatformSellRatio != 0 {
|
||||
t.Fatalf("onboard must not activate default 3.5/4 split, got %+v", onboardResp.Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPublicSiteInfo(t *testing.T) {
|
||||
@@ -532,34 +545,43 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) {
|
||||
t.Fatalf("UpdateReceivedListingStatus failed: %v", err)
|
||||
}
|
||||
|
||||
// 2. Settlement batch settles and credits wallet
|
||||
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now()}
|
||||
// 2. Settlement batch only credits the site party, never the platform share.
|
||||
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now().Add(-time.Hour)}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch := model.DistributionSettlementBatch{
|
||||
SiteID: site.ID,
|
||||
BatchNo: "B-WALLET-01",
|
||||
PeriodStart: time.Now().Add(-24 * time.Hour),
|
||||
PeriodEnd: time.Now(),
|
||||
TotalAmountCent: 10000,
|
||||
Status: "pending",
|
||||
}
|
||||
if err := db.Create(&batch).Error; err != nil {
|
||||
order := model.RentalOrder{OrderNo: "RECV-WALLET-1", ListingID: listing.ID, AccountID: ga.ID, OwnerID: user.ID, RenterID: 9, Status: "completed", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batchItem := model.DistributionSettlementItem{
|
||||
BatchID: batch.ID,
|
||||
OrderID: 888,
|
||||
PartyID: site.ID,
|
||||
AmountCent: 10000,
|
||||
Status: "pending",
|
||||
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, NetPaidAmountCent: 9950, SplitBaseAmountCent: 9900, SettlementStatus: "pending"}
|
||||
if err := db.Create(&financial).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&batchItem).Error; err != nil {
|
||||
parties := []model.DistributionOrderParty{
|
||||
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 3500, ProfitAmountCent: 3500},
|
||||
{OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: PartyTypePlatform, PartyID: 0, IncomeAmountCent: 4000, ProfitAmountCent: 4000},
|
||||
}
|
||||
if err := db.Create(&parties).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Mark batch settled -> auto-credits merchant wallet
|
||||
batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{
|
||||
SiteID: site.ID,
|
||||
PeriodStart: time.Now().Add(-24 * time.Hour),
|
||||
PeriodEnd: time.Now().Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSettlementBatch failed: %v", err)
|
||||
}
|
||||
if batch.TotalAmountCent != 3500 {
|
||||
t.Fatalf("expected site share 3500, got %d", batch.TotalAmountCent)
|
||||
}
|
||||
var batchItems []model.DistributionSettlementItem
|
||||
if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 1 {
|
||||
t.Fatalf("platform party must not enter site batch, items=%d err=%v", len(batchItems), err)
|
||||
}
|
||||
|
||||
if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != nil {
|
||||
t.Fatalf("MarkSettlementBatch failed: %v", err)
|
||||
}
|
||||
@@ -568,14 +590,14 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetSiteWallet failed: %v", err)
|
||||
}
|
||||
if walletDTO.AvailableBalanceCent != 10000 || walletDTO.TotalSettledCent != 10000 {
|
||||
t.Fatalf("expected available balance 10000, got: %+v", walletDTO)
|
||||
if walletDTO.AvailableBalanceCent != 3500 || walletDTO.TotalSettledCent != 3500 {
|
||||
t.Fatalf("expected available balance 3500, got: %+v", walletDTO)
|
||||
}
|
||||
|
||||
// 3. Apply withdrawal
|
||||
wd, err := repo.ApplySiteWithdrawal(t.Context(), 0, site.ID, ApplySiteWithdrawalRequest{
|
||||
SiteID: site.ID,
|
||||
AmountCent: 4000,
|
||||
AmountCent: 1000,
|
||||
AccountType: "alipay",
|
||||
AccountName: "分销商李四",
|
||||
AccountNo: "13800005678",
|
||||
@@ -583,17 +605,16 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ApplySiteWithdrawal failed: %v", err)
|
||||
}
|
||||
if wd.AmountCent != 4000 || wd.Status != "pending" {
|
||||
if wd.AmountCent != 1000 || wd.Status != "pending" {
|
||||
t.Fatalf("unexpected withdrawal: %+v", wd)
|
||||
}
|
||||
|
||||
// Check balance after freeze
|
||||
walletAfterWD, err := repo.GetSiteWallet(t.Context(), 0, site.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSiteWallet after WD failed: %v", err)
|
||||
}
|
||||
if walletAfterWD.AvailableBalanceCent != 6000 || walletAfterWD.FrozenBalanceCent != 4000 {
|
||||
t.Fatalf("expected available 6000 frozen 4000, got: %+v", walletAfterWD)
|
||||
if walletAfterWD.AvailableBalanceCent != 2500 || walletAfterWD.FrozenBalanceCent != 1000 {
|
||||
t.Fatalf("expected available 2500 frozen 1000, got: %+v", walletAfterWD)
|
||||
}
|
||||
|
||||
// 4. List withdrawals
|
||||
@@ -610,9 +631,209 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetDashboardAnalytics failed: %v", err)
|
||||
}
|
||||
if analytics.TotalReceivedCount != 1 || analytics.AvailableBalanceCent != 6000 {
|
||||
if analytics.TotalReceivedCount != 1 || analytics.AvailableBalanceCent != 2500 {
|
||||
t.Fatalf("unexpected analytics: %+v", analytics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformSellSettlementCreditsOnlySiteShare(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
now := time.Now()
|
||||
|
||||
site := model.DistributionSite{SiteCode: "sell_site", SiteName: "卖出站", Domain: "sell.example.com", Status: "active", DisplayAccountEnabled: true}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listing := model.RentalListing{ListingNo: "L-MAIN-SELL", AccountID: 301, OwnerID: 1, Status: "published"}
|
||||
if err := db.Create(&listing).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionSiteAccount{SiteID: site.ID, AccountID: listing.AccountID, ListingID: listing.ID, DisplayStatus: "enabled"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := model.DistributionRuleVersion{
|
||||
SiteID: &site.ID, Mode: ModeTwoPartyPlatformReceive, Version: 1, Status: "active",
|
||||
PlatformReceiveRatio: 3.5, DistributorSellRatio: 4,
|
||||
ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 0,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
foundSite, foundRule, err := ResolveOrderSourceTx(db, "sell_site", listing.ID, listing.AccountID, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if foundRule.Mode != ModeTwoPartyPlatformReceive {
|
||||
t.Fatalf("mode = %s, want %s", foundRule.Mode, ModeTwoPartyPlatformReceive)
|
||||
}
|
||||
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "ORD-PLATFORM-SELL", ListingID: listing.ID, AccountID: listing.AccountID,
|
||||
OwnerID: 1, RenterID: 2, Status: "completed",
|
||||
DistributionSiteID: &foundSite.ID, DistributionRuleVersionID: &foundRule.ID, DistributionMode: foundRule.Mode,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil {
|
||||
t.Fatalf("SnapshotPaidOrderTx failed: %v", err)
|
||||
}
|
||||
|
||||
var parties []model.DistributionOrderParty
|
||||
if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&parties).Error; err != nil || len(parties) != 2 {
|
||||
t.Fatalf("parties = %d, err=%v", len(parties), err)
|
||||
}
|
||||
var siteIncome, platformIncome int64
|
||||
for _, party := range parties {
|
||||
switch party.PartyRole {
|
||||
case "distributor_sell":
|
||||
if party.PartyType != PartyTypeSite || party.PartyID != site.ID {
|
||||
t.Fatalf("site party = %+v", party)
|
||||
}
|
||||
siteIncome = party.IncomeAmountCent
|
||||
case "platform_receive":
|
||||
if party.PartyType != PartyTypePlatform || party.PartyID != 0 {
|
||||
t.Fatalf("platform party_id must be 0, got %+v", party)
|
||||
}
|
||||
platformIncome = party.IncomeAmountCent
|
||||
default:
|
||||
t.Fatalf("unexpected party %+v", party)
|
||||
}
|
||||
}
|
||||
if siteIncome <= 0 || platformIncome <= 0 {
|
||||
t.Fatalf("expected both site and platform income, site=%d platform=%d", siteIncome, platformIncome)
|
||||
}
|
||||
|
||||
batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{
|
||||
SiteID: site.ID, PeriodStart: now.Add(-time.Hour), PeriodEnd: now.Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch.TotalAmountCent != siteIncome {
|
||||
t.Fatalf("batch total = %d, want site income %d", batch.TotalAmountCent, siteIncome)
|
||||
}
|
||||
var batchItems []model.DistributionSettlementItem
|
||||
if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 1 {
|
||||
t.Fatalf("platform party must not appear in site batch, items=%d err=%v", len(batchItems), err)
|
||||
}
|
||||
|
||||
if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
walletDTO, err := repo.GetSiteWallet(t.Context(), 0, site.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if walletDTO.AvailableBalanceCent != siteIncome {
|
||||
t.Fatalf("wallet Δ = %d, want %d", walletDTO.AvailableBalanceCent, siteIncome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettlementBatchSplitsThreePartyBySite(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
now := time.Now()
|
||||
|
||||
siteA := model.DistributionSite{SiteCode: "party_a", SiteName: "A", Domain: "a.example.com", Status: "active", DisplayAccountEnabled: true}
|
||||
siteB := model.DistributionSite{SiteCode: "party_b", SiteName: "B", Domain: "b.example.com", Status: "active", DisplayAccountEnabled: true}
|
||||
if err := db.Create(&siteA).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&siteB).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := model.DistributionRuleVersion{SiteID: &siteB.ID, Mode: ModeThreeParty, Version: 1, Status: "active", EffectiveFrom: now.Add(-time.Hour)}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := model.RentalOrder{OrderNo: "ORD-3P", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", DistributionSiteID: &siteB.ID, DistributionRuleVersionID: &rule.ID}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: siteB.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, SettlementStatus: "pending"}
|
||||
if err := db.Create(&financial).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&[]model.DistributionOrderParty{
|
||||
{OrderID: order.ID, SiteID: siteB.ID, PartyRole: "distributor_a", PartyType: PartyTypeSite, PartyID: siteA.ID, IncomeAmountCent: 350},
|
||||
{OrderID: order.ID, SiteID: siteB.ID, PartyRole: "distributor_b", PartyType: PartyTypeSite, PartyID: siteB.ID, IncomeAmountCent: 400},
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
period := CreateSettlementBatchRequest{PeriodStart: now.Add(-time.Hour), PeriodEnd: now.Add(time.Hour)}
|
||||
batchA, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: siteA.ID, PeriodStart: period.PeriodStart, PeriodEnd: period.PeriodEnd})
|
||||
if err != nil || batchA.TotalAmountCent != 350 {
|
||||
t.Fatalf("site A batch = %+v err=%v", batchA, err)
|
||||
}
|
||||
batchB, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: siteB.ID, PeriodStart: period.PeriodStart, PeriodEnd: period.PeriodEnd})
|
||||
if err != nil || batchB.TotalAmountCent != 400 {
|
||||
t.Fatalf("site B batch = %+v err=%v", batchB, err)
|
||||
}
|
||||
|
||||
if _, err := repo.MarkSettlementBatch(t.Context(), 0, batchA.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var financialAfterA model.DistributionOrderFinancial
|
||||
if err := db.Where("order_id = ?", order.ID).First(&financialAfterA).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if financialAfterA.SettlementStatus != "pending" {
|
||||
t.Fatalf("financial must stay pending until all site parties settle, got %s", financialAfterA.SettlementStatus)
|
||||
}
|
||||
|
||||
if _, err := repo.MarkSettlementBatch(t.Context(), 0, batchB.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var financialAfterB model.DistributionOrderFinancial
|
||||
if err := db.Where("order_id = ?", order.ID).First(&financialAfterB).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if financialAfterB.SettlementStatus != "settled" {
|
||||
t.Fatalf("financial status = %s, want settled", financialAfterB.SettlementStatus)
|
||||
}
|
||||
|
||||
walletA, err := repo.GetSiteWallet(t.Context(), 0, siteA.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
walletB, err := repo.GetSiteWallet(t.Context(), 0, siteB.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if walletA.AvailableBalanceCent != 350 || walletB.AvailableBalanceCent != 400 {
|
||||
t.Fatalf("wallets A=%d B=%d", walletA.AvailableBalanceCent, walletB.AvailableBalanceCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveMoneyMovementFrozenInProduction(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "production")
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
site := model.DistributionSite{SiteCode: "frozen", SiteName: "冻结站", Domain: "frozen.example.com", Status: "active"}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch := model.DistributionSettlementBatch{
|
||||
SiteID: site.ID, BatchNo: "B-FROZEN", PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now(),
|
||||
TotalAmountCent: 100, Status: "draft",
|
||||
}
|
||||
if err := db.Create(&batch).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionSettlementItem{BatchID: batch.ID, OrderID: 1, PartyID: 1, AmountCent: 100, Status: "pending"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != ErrLiveMoneyMovementFrozen {
|
||||
t.Fatalf("settle err = %v", err)
|
||||
}
|
||||
if _, err := repo.ApplySiteWithdrawal(t.Context(), 0, site.ID, ApplySiteWithdrawalRequest{
|
||||
SiteID: site.ID, AmountCent: 1, AccountType: "alipay", AccountName: "x", AccountNo: "1",
|
||||
}); err != ErrLiveMoneyMovementFrozen {
|
||||
t.Fatalf("withdraw err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user