package distribution import ( "testing" "time" "hfb_sys/backend/internal/model" ) func TestApplyRefundTxSetsReleaseAtForCompletedOrder(t *testing.T) { db := distributionTestDB(t) now := time.Now() site := mustCreateDistSite(t, db, "rel_site", "释放测试站", "rel.dist.com") if err := db.Model(&site).Update("settlement_hold_days", 3).Error; err != nil { t.Fatal(err) } rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) settledAt := now.Add(-time.Hour).Truncate(time.Second) order := model.RentalOrder{ OrderNo: "ORD-RELEASE-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, SettledAt: &settledAt, } if err := db.Create(&order).Error; err != nil { t.Fatal(err) } if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil { t.Fatal(err) } if err := ApplyRefundTx(db, order.ID, 0); err != nil { t.Fatal(err) } var financial model.DistributionOrderFinancial if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil { t.Fatal(err) } if financial.ReleaseAt == nil { t.Fatalf("release_at not set: %+v", financial) } want := settledAt.AddDate(0, 0, 3) if !financial.ReleaseAt.Equal(want) { t.Fatalf("release_at = %v, want %v", financial.ReleaseAt, want) } } func TestAutoReleaseDueSettlements(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) now := time.Now() site := mustCreateDistSite(t, db, "auto_site", "自动释放站", "auto.dist.com") rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) past := now.Add(-time.Minute) order := model.RentalOrder{ OrderNo: "ORD-AUTO-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, } if err := db.Create(&order).Error; err != nil { t.Fatal(err) } financial := model.DistributionOrderFinancial{ OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000, SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past, } if err := db.Create(&financial).Error; err != nil { t.Fatal(err) } parties := []model.DistributionOrderParty{ {OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400}, {OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: PartyTypePlatform, PartyID: 0, IncomeAmountCent: 1600}, } if err := db.Create(&parties).Error; err != nil { t.Fatal(err) } released, err := repo.AutoReleaseDueSettlements(t.Context()) if err != nil { t.Fatalf("AutoReleaseDueSettlements failed: %v", err) } if released != 1 { t.Fatalf("released = %d, want 1", released) } var updated model.DistributionOrderFinancial if err := db.Where("order_id = ?", order.ID).First(&updated).Error; err != nil { t.Fatal(err) } if updated.SettlementStatus != "settled" || updated.ReleasedAt == nil { t.Fatalf("financial not released: %+v", updated) } var account model.WalletAccount if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil { t.Fatal(err) } if account.AvailableBalanceCent != 1400 { t.Fatalf("site wallet = %d, want 1400", account.AvailableBalanceCent) } var batch model.DistributionSettlementBatch if err := db.Where("site_id = ? AND status = 'settled'", site.ID).First(&batch).Error; err != nil { t.Fatal(err) } if batch.TotalAmountCent != 1400 || len(batch.BatchNo) < 2 || batch.BatchNo[:2] != autoBatchPrefix { t.Fatalf("batch = %+v", batch) } var itemCount int64 if err := db.Model(&model.DistributionSettlementItem{}). Where("batch_id = ? AND status = 'settled'", batch.ID).Count(&itemCount).Error; err != nil { t.Fatal(err) } if itemCount != 1 { t.Fatalf("settled items = %d, want 1", itemCount) } // 幂等:重复执行不再释放,也不再入账。 released, err = repo.AutoReleaseDueSettlements(t.Context()) if err != nil { t.Fatal(err) } if released != 0 { t.Fatalf("second run released = %d, want 0", released) } if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil { t.Fatal(err) } if account.AvailableBalanceCent != 1400 { t.Fatalf("site wallet after rerun = %d, want 1400", account.AvailableBalanceCent) } } func TestAutoReleaseSkipsOrderInRefund(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) now := time.Now() site := mustCreateDistSite(t, db, "hold_site", "退款拦截站", "hold.dist.com") rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) past := now.Add(-time.Minute) order := model.RentalOrder{ OrderNo: "ORD-HOLD-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "pending_review", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, } if err := db.Create(&order).Error; err != nil { t.Fatal(err) } financial := model.DistributionOrderFinancial{ OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000, SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past, } if err := db.Create(&financial).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderParty{ OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400, }).Error; err != nil { t.Fatal(err) } released, err := repo.AutoReleaseDueSettlements(t.Context()) if err != nil { t.Fatal(err) } if released != 0 { t.Fatalf("released = %d, want 0 while refund is pending", released) } } func TestRefundAfterReleaseReversesSiteWallet(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) now := time.Now() site := mustCreateDistSite(t, db, "rev_site", "回冲测试站", "rev.dist.com") rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) past := now.Add(-time.Minute) order := model.RentalOrder{ OrderNo: "ORD-REV-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, } if err := db.Create(&order).Error; err != nil { t.Fatal(err) } financial := model.DistributionOrderFinancial{ OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000, SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past, } if err := db.Create(&financial).Error; err != nil { t.Fatal(err) } if err := db.Create(&[]model.DistributionOrderParty{ {OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400}, {OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: PartyTypePlatform, PartyID: 0, IncomeAmountCent: 1600}, }).Error; err != nil { t.Fatal(err) } if _, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil { t.Fatal(err) } // 释放后退款 3000:利润 1000,分站份额降为 350,应回冲 1050。 if err := ApplyRefundTx(db, order.ID, 3000); err != nil { t.Fatal(err) } var account model.WalletAccount if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil { t.Fatal(err) } if account.AvailableBalanceCent != 350 { t.Fatalf("wallet after reversal = %d, want 350", account.AvailableBalanceCent) } var updated model.DistributionOrderFinancial if err := db.Where("order_id = ?", order.ID).First(&updated).Error; err != nil { t.Fatal(err) } if updated.SettlementStatus != "settled" { t.Fatalf("released financial must stay settled, got %s", updated.SettlementStatus) } // 余额被提空后再次回冲应允许扣成负数,避免回冲失败。 if err := db.Model(&model.WalletAccount{}).Where("id = ?", account.ID). Update("available_balance_cent", 0).Error; err != nil { t.Fatal(err) } if err := ApplyRefundTx(db, order.ID, 10000); err != nil { t.Fatalf("reverse with empty wallet failed: %v", err) } if err := db.Where("id = ?", account.ID).First(&account).Error; err != nil { t.Fatal(err) } if account.AvailableBalanceCent != -350 { t.Fatalf("wallet = %d, want -350 (allow negative on reversal)", account.AvailableBalanceCent) } } func TestEnsureCompletedReleaseAtTxSetsReleaseAt(t *testing.T) { db := distributionTestDB(t) now := time.Now() site := mustCreateDistSite(t, db, "ens_site", "完单释放站", "ens.dist.com") if err := db.Model(&site).Update("settlement_hold_days", 2).Error; err != nil { t.Fatal(err) } rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) settledAt := now.Add(-time.Hour).Truncate(time.Second) order := model.RentalOrder{ OrderNo: "ORD-ENS-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, SettledAt: &settledAt, } if err := db.Create(&order).Error; err != nil { t.Fatal(err) } if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil { t.Fatal(err) } if err := EnsureCompletedReleaseAtTx(db, order.ID); err != nil { t.Fatal(err) } var financial model.DistributionOrderFinancial if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil { t.Fatal(err) } if financial.ReleaseAt == nil { t.Fatal("release_at not set") } want := settledAt.AddDate(0, 0, 2) if !financial.ReleaseAt.Equal(want) { t.Fatalf("release_at = %v, want %v", financial.ReleaseAt, want) } } func TestAutoReleaseAppendsSameDayBatch(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) now := time.Now() site := mustCreateDistSite(t, db, "day_site", "同日追加站", "day.dist.com") rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) past := now.Add(-time.Minute) seed := func(orderNo string) { t.Helper() order := model.RentalOrder{ OrderNo: orderNo, ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, } if err := db.Create(&order).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderFinancial{ OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000, SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past, }).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderParty{ OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400, }).Error; err != nil { t.Fatal(err) } } seed("ORD-DAY-1") if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 1 { t.Fatalf("first release = %d err=%v, want 1", released, err) } var firstBatch model.DistributionSettlementBatch if err := db.Where("site_id = ?", site.ID).First(&firstBatch).Error; err != nil { t.Fatal(err) } seed("ORD-DAY-2") if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 1 { t.Fatalf("second release = %d err=%v, want 1", released, err) } var batches []model.DistributionSettlementBatch if err := db.Where("site_id = ? AND batch_no LIKE ?", site.ID, autoBatchPrefix+"%").Find(&batches).Error; err != nil { t.Fatal(err) } if len(batches) != 1 { t.Fatalf("same-day auto batches = %d, want 1", len(batches)) } if batches[0].ID != firstBatch.ID { t.Fatalf("second release created a new batch %d, want %d", batches[0].ID, firstBatch.ID) } if batches[0].TotalAmountCent != 2800 { t.Fatalf("batch total = %d, want 2800", batches[0].TotalAmountCent) } var itemCount int64 if err := db.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", batches[0].ID).Count(&itemCount).Error; err != nil { t.Fatal(err) } if itemCount != 2 { t.Fatalf("batch items = %d, want 2", itemCount) } var account model.WalletAccount if err := db.Where("user_id = 0 AND distribution_site_id = ?", site.ID).First(&account).Error; err != nil { t.Fatal(err) } if account.AvailableBalanceCent != 2800 { t.Fatalf("wallet = %d, want 2800", account.AvailableBalanceCent) } } func TestAutoReleaseSkipsRefundingOrder(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) now := time.Now() site := mustCreateDistSite(t, db, "rf_site", "退款中拦截站", "rf.dist.com") rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) past := now.Add(-time.Minute) order := model.RentalOrder{ OrderNo: "ORD-RF-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "refunding", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, } if err := db.Create(&order).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderFinancial{ OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000, SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past, }).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderParty{ OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400, }).Error; err != nil { t.Fatal(err) } released, err := repo.AutoReleaseDueSettlements(t.Context()) if err != nil { t.Fatal(err) } if released != 0 { t.Fatalf("released = %d, want 0 while refunding", released) } } func TestThreePartyRefundAfterReleaseReversesReceiverWallet(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) now := time.Now() siteA := mustCreateDistSite(t, db, "rev_a", "收号站", "reva.dist.com") siteB := mustCreateDistSite(t, db, "rev_b", "卖出站", "revb.dist.com") rule := mustCreateActiveRule(t, db, siteB.ID, ModeThreeParty, now) past := now.Add(-time.Minute) order := model.RentalOrder{ OrderNo: "ORD-3P-REV", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none", DistributionSiteID: &siteB.ID, DistributionRuleVersionID: &rule.ID, } if err := db.Create(&order).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderFinancial{ OrderID: order.ID, SiteID: siteB.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, NetPaidAmountCent: 10000, SplitBaseAmountCent: 4000, SettlementStatus: "pending", ReleaseAt: &past, }).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: 1400, Ratio: 35}, {OrderID: order.ID, SiteID: siteB.ID, PartyRole: "distributor_b", PartyType: PartyTypeSite, PartyID: siteB.ID, IncomeAmountCent: 1600, Ratio: 40}, }).Error; err != nil { t.Fatal(err) } if released, err := repo.AutoReleaseDueSettlements(t.Context()); err != nil || released != 2 { t.Fatalf("released = %d err=%v, want 2", released, err) } // 退款 3000 后利润 1000:A 350、B 400;回冲必须打到 PartyID(收号站 A),不能打到快照 SiteID(卖出站 B)。 if err := ApplyRefundTx(db, order.ID, 3000); err != nil { t.Fatal(err) } var walletA, walletB model.WalletAccount if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteA.ID).First(&walletA).Error; err != nil { t.Fatal(err) } if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteB.ID).First(&walletB).Error; err != nil { t.Fatal(err) } if walletA.AvailableBalanceCent != 350 { t.Fatalf("receiver wallet A = %d, want 350 (clawback on PartyID)", walletA.AvailableBalanceCent) } if walletB.AvailableBalanceCent != 400 { t.Fatalf("seller wallet B = %d, want 400", walletB.AvailableBalanceCent) } } func TestAutoReleaseDueSettlementsIncludesClosedOrders(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) now := time.Now() site := mustCreateDistSite(t, db, "closed_site", "关单释放站", "closed.dist.com") rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) past := now.Add(-time.Minute) order := model.RentalOrder{ OrderNo: "ORD-CLOSED-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "closed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, } if err := db.Create(&order).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderFinancial{ OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, NetPaidAmountCent: 0, SplitBaseAmountCent: 0, SettlementStatus: "pending", ReleaseAt: &past, }).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderParty{ OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 700, }).Error; err != nil { t.Fatal(err) } released, err := repo.AutoReleaseDueSettlements(t.Context()) if err != nil || released != 1 { t.Fatalf("closed order release = %d err=%v, want 1", released, err) } }