- Add internal/service/finance/types.go: AssetType enum, param/result structs - Add internal/service/finance/service.go: Service interface, read-only ctor - Add internal/service/finance/query_user.go: QueryUserProfitLoss (4 fan-out scans) - Add internal/service/finance/query_activity.go: QueryActivityProfitLoss (4 fan-out scans) - Add internal/service/finance/service_test.go: 22 integration tests (all pass) - Wire finance.Service into admin handler (admin.go) - Replace dashboard_activity cost scan with finance.Service call (D-09: value_cents single source of truth) - Revenue/gamepass/draw-count scans unchanged; response schema fully compatible Co-Authored-By: claude-flow <ruv@ruv.net>
386 lines
15 KiB
Go
386 lines
15 KiB
Go
package finance
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"bindbox-game/internal/pkg/logger"
|
|
"bindbox-game/internal/repository/mysql"
|
|
"bindbox-game/internal/repository/mysql/model"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// newTestSvc creates an in-memory SQLite repo, creates all required tables,
|
|
// and returns (Service, *gorm.DB) for test use.
|
|
// NOTE: Uses manual CREATE TABLE instead of AutoMigrate to avoid CURRENT_TIMESTAMP(3)
|
|
// SQLite incompatibility present in the GORM model tags.
|
|
func newTestSvc(t *testing.T) (Service, *gorm.DB) {
|
|
t.Helper()
|
|
repo, err := mysql.NewSQLiteRepoForTest()
|
|
require.NoError(t, err)
|
|
db := repo.GetDbR()
|
|
|
|
// Create tables manually — SQLite does not support CURRENT_TIMESTAMP(3)
|
|
// which is present in the GORM model default tags.
|
|
stmts := []string{
|
|
`CREATE TABLE IF NOT EXISTS orders (
|
|
id integer primary key,
|
|
created_at datetime,
|
|
updated_at datetime,
|
|
user_id integer not null default 0,
|
|
order_no text not null default '',
|
|
source_type integer not null default 1,
|
|
total_amount integer not null default 0,
|
|
discount_amount integer not null default 0,
|
|
points_amount integer not null default 0,
|
|
actual_amount integer not null default 0,
|
|
status integer not null default 1,
|
|
pay_preorder_id integer,
|
|
paid_at datetime,
|
|
cancelled_at datetime,
|
|
user_address_id integer,
|
|
is_consumed integer not null default 0,
|
|
points_ledger_id integer,
|
|
coupon_id integer,
|
|
item_card_id integer,
|
|
remark text,
|
|
ext_order_id text not null default ''
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS user_inventory (
|
|
id integer primary key,
|
|
created_at datetime,
|
|
updated_at datetime,
|
|
user_id integer not null default 0,
|
|
product_id integer,
|
|
value_cents integer not null default 0,
|
|
value_source integer not null default 0,
|
|
value_snapshot_at datetime,
|
|
order_id integer,
|
|
activity_id integer,
|
|
reward_id integer,
|
|
status integer not null default 1,
|
|
shipping_no text not null default '',
|
|
remark text
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS user_points_ledger (
|
|
id integer primary key,
|
|
created_at datetime,
|
|
user_id integer not null default 0,
|
|
action text not null default '',
|
|
points integer not null default 0,
|
|
ref_table text,
|
|
ref_id text,
|
|
remark text
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS user_coupon_ledger (
|
|
id integer primary key,
|
|
user_id integer not null default 0,
|
|
user_coupon_id integer not null default 0,
|
|
change_amount integer not null default 0,
|
|
balance_after integer not null default 0,
|
|
order_id integer,
|
|
action text not null default '',
|
|
created_at datetime
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS activity_draw_logs (id integer primary key, order_id integer, issue_id integer, user_id integer)`,
|
|
`CREATE TABLE IF NOT EXISTS activity_issues (id integer primary key, activity_id integer not null)`,
|
|
`CREATE TABLE IF NOT EXISTS activities (id integer primary key, price_draw integer not null default 0)`,
|
|
`CREATE TABLE IF NOT EXISTS user_item_cards (id integer primary key, card_id integer)`,
|
|
`CREATE TABLE IF NOT EXISTS system_item_cards (id integer primary key, reward_multiplier_x1000 integer)`,
|
|
`CREATE TABLE IF NOT EXISTS system_configs (id integer primary key, config_key text, config_value text)`,
|
|
}
|
|
for _, stmt := range stmts {
|
|
require.NoError(t, db.Exec(stmt).Error)
|
|
}
|
|
|
|
l, err := logger.NewCustomLogger(logger.WithOutputInConsole())
|
|
require.NoError(t, err)
|
|
svc := New(l, repo)
|
|
return svc, db
|
|
}
|
|
|
|
// --- Seed helpers ---
|
|
|
|
func seedOrder(t *testing.T, db *gorm.DB, o model.Orders) {
|
|
t.Helper()
|
|
require.NoError(t, db.Create(&o).Error)
|
|
}
|
|
|
|
func seedInventory(t *testing.T, db *gorm.DB, inv model.UserInventory) {
|
|
t.Helper()
|
|
require.NoError(t, db.Create(&inv).Error)
|
|
}
|
|
|
|
func seedPointsLedger(t *testing.T, db *gorm.DB, row model.UserPointsLedger) {
|
|
t.Helper()
|
|
require.NoError(t, db.Create(&row).Error)
|
|
}
|
|
|
|
func seedCouponLedger(t *testing.T, db *gorm.DB, row model.UserCouponLedger) {
|
|
t.Helper()
|
|
require.NoError(t, db.Create(&row).Error)
|
|
}
|
|
|
|
// seedActivitySetup creates minimal activity + issue + draw_log for JOIN tests.
|
|
func seedActivitySetup(t *testing.T, db *gorm.DB, activityID, issueID, orderID, userID int64, priceDraw int64) {
|
|
t.Helper()
|
|
require.NoError(t, db.Exec("INSERT OR IGNORE INTO activities (id, price_draw) VALUES (?, ?)", activityID, priceDraw).Error)
|
|
require.NoError(t, db.Exec("INSERT OR IGNORE INTO activity_issues (id, activity_id) VALUES (?, ?)", issueID, activityID).Error)
|
|
require.NoError(t, db.Exec("INSERT OR IGNORE INTO activity_draw_logs (id, order_id, issue_id, user_id) VALUES (?, ?, ?, ?)", orderID*100+issueID, orderID, issueID, userID).Error)
|
|
}
|
|
|
|
// --- Plan 01 contract tests ---
|
|
|
|
func TestAssetTypeConstants(t *testing.T) {
|
|
require.Equal(t, AssetType(0), AssetTypeAll)
|
|
require.Equal(t, AssetType(1), AssetTypePoints)
|
|
require.Equal(t, AssetType(2), AssetTypeCoupon)
|
|
require.Equal(t, AssetType(3), AssetTypeItemCard)
|
|
require.Equal(t, AssetType(4), AssetTypeProduct)
|
|
require.Equal(t, AssetType(5), AssetTypeFragment)
|
|
}
|
|
|
|
func TestNew_ReturnsService(t *testing.T) {
|
|
svc, _ := newTestSvc(t)
|
|
require.NotNil(t, svc)
|
|
}
|
|
|
|
func TestQueryUserProfitLoss_EmptyParams_ReturnsNoError(t *testing.T) {
|
|
svc, _ := newTestSvc(t)
|
|
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{})
|
|
require.NoError(t, err)
|
|
_ = result
|
|
}
|
|
|
|
func TestQueryActivityProfitLoss_EmptyParams_ReturnsNoError(t *testing.T) {
|
|
svc, _ := newTestSvc(t)
|
|
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{})
|
|
require.NoError(t, err)
|
|
_ = result
|
|
}
|
|
|
|
// --- Plan 02 QueryUserProfitLoss integration tests ---
|
|
|
|
func TestQueryUserProfitLoss_CashOrder(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedOrder(t, db, model.Orders{
|
|
ID: 1, UserID: 101, Status: 2,
|
|
SourceType: 2, OrderNo: "O20260321001",
|
|
ActualAmount: 800, DiscountAmount: 200,
|
|
})
|
|
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{101}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, int64(1000), result.TotalRevenue, "cash revenue = actual + discount")
|
|
}
|
|
|
|
func TestQueryUserProfitLoss_RefundedOrderExcluded(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedOrder(t, db, model.Orders{
|
|
ID: 2, UserID: 102, Status: 4, // refunded
|
|
SourceType: 2, OrderNo: "O20260321002",
|
|
ActualAmount: 1000, DiscountAmount: 0,
|
|
})
|
|
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{102}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, int64(0), result.TotalRevenue, "refunded order must not contribute revenue")
|
|
}
|
|
|
|
func TestQueryUserProfitLoss_VoidedInventoryExcluded(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedInventory(t, db, model.UserInventory{
|
|
ID: 1, UserID: 103, Status: 2, // voided status
|
|
ValueCents: 5000, OrderID: 0,
|
|
})
|
|
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{103}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, int64(0), result.TotalCost, "voided inventory (status=2) must not contribute cost")
|
|
}
|
|
|
|
func TestQueryUserProfitLoss_RemarkVoidExcluded(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedInventory(t, db, model.UserInventory{
|
|
ID: 2, UserID: 104, Status: 1, // valid status
|
|
ValueCents: 3000, OrderID: 0,
|
|
Remark: "void_20260101",
|
|
})
|
|
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{104}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, int64(0), result.TotalCost, "inventory with remark containing 'void' must not contribute cost")
|
|
}
|
|
|
|
func TestQueryUserProfitLoss_LegacyZeroOrderID(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedInventory(t, db, model.UserInventory{
|
|
ID: 3, UserID: 105, Status: 1,
|
|
ValueCents: 2000, OrderID: 0,
|
|
Remark: "",
|
|
})
|
|
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{105}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, int64(2000), result.TotalCost, "legacy inventory with order_id=0 MUST be included in cost (PNL-08)")
|
|
}
|
|
|
|
func TestQueryUserProfitLoss_AllUsers(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedOrder(t, db, model.Orders{ID: 10, UserID: 201, Status: 2, SourceType: 2, OrderNo: "O001", ActualAmount: 100})
|
|
seedOrder(t, db, model.Orders{ID: 11, UserID: 202, Status: 2, SourceType: 2, OrderNo: "O002", ActualAmount: 200})
|
|
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
userIDs := make(map[int64]bool)
|
|
for _, d := range result.Details {
|
|
userIDs[d.UserID] = true
|
|
}
|
|
require.True(t, userIDs[201], "user 201 must be in results")
|
|
require.True(t, userIDs[202], "user 202 must be in results")
|
|
}
|
|
|
|
func TestQueryUserProfitLoss_FilterByUserID(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedOrder(t, db, model.Orders{ID: 20, UserID: 301, Status: 2, SourceType: 2, OrderNo: "O003", ActualAmount: 500})
|
|
seedOrder(t, db, model.Orders{ID: 21, UserID: 302, Status: 2, SourceType: 2, OrderNo: "O004", ActualAmount: 600})
|
|
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{301}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
for _, d := range result.Details {
|
|
require.Equal(t, int64(301), d.UserID, "only user 301 should appear")
|
|
}
|
|
}
|
|
|
|
func TestQueryUserProfitLoss_ProfitCalculation(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedOrder(t, db, model.Orders{ID: 30, UserID: 401, Status: 2, SourceType: 2, OrderNo: "O005", ActualAmount: 1000, DiscountAmount: 200})
|
|
seedInventory(t, db, model.UserInventory{ID: 10, UserID: 401, Status: 1, ValueCents: 800, OrderID: 30, Remark: ""})
|
|
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{401}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, int64(1200), result.TotalRevenue)
|
|
require.Equal(t, int64(800), result.TotalCost)
|
|
require.Equal(t, int64(400), result.TotalProfit, "profit = revenue - cost")
|
|
}
|
|
|
|
func TestQueryUserProfitLoss_ResultShape(t *testing.T) {
|
|
svc, _ := newTestSvc(t)
|
|
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.NotNil(t, result.Details, "Details must be non-nil slice")
|
|
require.NotNil(t, result.Breakdown, "Breakdown must be non-nil slice (empty for Phase 1)")
|
|
}
|
|
|
|
// --- Plan 03 QueryActivityProfitLoss integration tests ---
|
|
|
|
func TestQueryActivityProfitLoss_CashOrderRevenue(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedOrder(t, db, model.Orders{
|
|
ID: 50, UserID: 501, Status: 2,
|
|
SourceType: 2, OrderNo: "A001",
|
|
ActualAmount: 600, DiscountAmount: 150,
|
|
})
|
|
seedActivitySetup(t, db, 1001, 2001, 50, 501, 100)
|
|
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1001}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, int64(750), result.TotalRevenue, "cash revenue = actual(600) + discount(150)")
|
|
}
|
|
|
|
func TestQueryActivityProfitLoss_RefundedOrderExcluded(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedOrder(t, db, model.Orders{
|
|
ID: 51, UserID: 502, Status: 4, // refunded
|
|
SourceType: 2, OrderNo: "A002",
|
|
ActualAmount: 800, DiscountAmount: 0,
|
|
})
|
|
seedActivitySetup(t, db, 1002, 2002, 51, 502, 100)
|
|
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1002}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, int64(0), result.TotalRevenue, "refunded order must not contribute revenue")
|
|
}
|
|
|
|
func TestQueryActivityProfitLoss_VoidedInventoryExcluded(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedInventory(t, db, model.UserInventory{
|
|
ID: 20, UserID: 503, ActivityID: 1003,
|
|
Status: 2, // voided
|
|
ValueCents: 4000, OrderID: 0,
|
|
})
|
|
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1003}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, int64(0), result.TotalCost, "voided inventory must not contribute cost")
|
|
}
|
|
|
|
func TestQueryActivityProfitLoss_LegacyZeroOrderID(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedInventory(t, db, model.UserInventory{
|
|
ID: 21, UserID: 504, ActivityID: 1004,
|
|
Status: 1, ValueCents: 3500, OrderID: 0,
|
|
Remark: "",
|
|
})
|
|
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1004}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, int64(3500), result.TotalCost, "legacy inventory with order_id=0 MUST be included in cost (PNL-08)")
|
|
}
|
|
|
|
func TestQueryActivityProfitLoss_AllActivities(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedOrder(t, db, model.Orders{ID: 60, UserID: 601, Status: 2, SourceType: 2, OrderNo: "A010", ActualAmount: 100})
|
|
seedOrder(t, db, model.Orders{ID: 61, UserID: 602, Status: 2, SourceType: 2, OrderNo: "A011", ActualAmount: 200})
|
|
seedActivitySetup(t, db, 2001, 3001, 60, 601, 50)
|
|
seedActivitySetup(t, db, 2002, 3002, 61, 602, 50)
|
|
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
actIDs := make(map[int64]bool)
|
|
for _, d := range result.Details {
|
|
actIDs[d.ActivityID] = true
|
|
}
|
|
require.True(t, actIDs[2001], "activity 2001 must be in results")
|
|
require.True(t, actIDs[2002], "activity 2002 must be in results")
|
|
}
|
|
|
|
func TestQueryActivityProfitLoss_FilterByActivityID(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedOrder(t, db, model.Orders{ID: 70, UserID: 701, Status: 2, SourceType: 2, OrderNo: "A020", ActualAmount: 300})
|
|
seedOrder(t, db, model.Orders{ID: 71, UserID: 702, Status: 2, SourceType: 2, OrderNo: "A021", ActualAmount: 400})
|
|
seedActivitySetup(t, db, 3001, 4001, 70, 701, 50)
|
|
seedActivitySetup(t, db, 3002, 4002, 71, 702, 50)
|
|
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{3001}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
for _, d := range result.Details {
|
|
require.Equal(t, int64(3001), d.ActivityID, "only activity 3001 should appear")
|
|
}
|
|
}
|
|
|
|
func TestQueryActivityProfitLoss_ProfitCalculation(t *testing.T) {
|
|
svc, db := newTestSvc(t)
|
|
seedOrder(t, db, model.Orders{ID: 80, UserID: 801, Status: 2, SourceType: 2, OrderNo: "A030", ActualAmount: 2000, DiscountAmount: 500})
|
|
seedActivitySetup(t, db, 4001, 5001, 80, 801, 100)
|
|
seedInventory(t, db, model.UserInventory{ID: 30, UserID: 801, ActivityID: 4001, Status: 1, ValueCents: 1200, OrderID: 80})
|
|
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{4001}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, int64(2500), result.TotalRevenue, "revenue = actual(2000) + discount(500)")
|
|
require.Equal(t, int64(1200), result.TotalCost)
|
|
require.Equal(t, int64(1300), result.TotalProfit, "profit = 2500 - 1200")
|
|
}
|
|
|
|
func TestQueryActivityProfitLoss_ResultShape(t *testing.T) {
|
|
svc, _ := newTestSvc(t)
|
|
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.NotNil(t, result.Details, "Details must be non-nil slice")
|
|
require.NotNil(t, result.Breakdown, "Breakdown must be non-nil empty slice")
|
|
}
|