利润不再扣支付通道费;站点可配置结算冷却期,到期自动入账,已释放后退款从收款站钱包回冲。同时修复当日自动批次无法追加、三方回冲打错钱包,以及测试库自增重置被 MySQL 统计缓存跳过的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
444 lines
16 KiB
Go
444 lines
16 KiB
Go
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)
|
||
}
|
||
}
|