35 KiB
Phase 1: Core P&L Functions - Research
Researched: 2026-03-21 Domain: Go service layer financial aggregation — multi-dimensional P&L query functions in an existing GORM/MySQL codebase Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
Revenue Attribution Rules
- D-01: 一个订单只对应一个活动(1:1 关系),不需要比例分摊逻辑(跳过 dashboard 中的 two-level subquery 方案)
- D-02: Game-pass 收入按 draw_count × activity_unit_price 计算,每个活动独立计算
- D-03: 用户维度直接汇总用户所有订单,不做跨活动分摊
Function Signature Design
- D-04: 两个独立的参数结构体:
UserProfitLossParams和ActivityProfitLossParams(不共享) - D-05: 返回
(*ProfitLossResult, error)— Go 标准模式,error 时 result 为 nil - D-06: ProfitLossResult 包含汇总(TotalResult)+ 明细切片(
[]ProfitLossDetail,每个元素含 UserID/ActivityID 字段) - D-07: 参数全部可选:空 []int64 = 统计全部,nil time = 不限时间,AssetType=0 = 全部类型
Cost Source Mapping
- D-08: 成本数据分布在多张表:user_inventory(实物/道具卡)、user_points_ledger(积分)、user_coupon_ledger(优惠券)、fragment_synthesis_logs(碎片,Phase 2)
- D-09: 实物商品/道具卡成本以
user_inventory.value_cents为准(单一真相源),不需要 fallback chain - D-10: 积分通过 system_configs 表中的固定汇率换算为金额(如 100积分 = 1元)
- D-11: 优惠券成本 = 优惠券面值(discount_amount)
Claude's Discretion
- 具体 SQL 查询结构和 GORM 调用方式
- ProfitLossDetail 内部字段的精确命名
- fan-out 查询的拆分粒度和合并策略
- 单元测试的具体用例选择
Deferred Ideas (OUT OF SCOPE)
- Per-asset-type breakdown (Phase 2) — struct field defined here as empty slice, populated in Phase 2
- Fragment synthesis cost integration (Phase 2) — AST-03
- Redis caching wrapper (v2)
- Admin API endpoints for frontend (v2) </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| PNL-01 | 函数接收 ProfitLossParams 参数结构体,所有字段可选(资产类型、维度ID、时间范围) | D-04, D-07; param struct pattern confirmed from user.go constructor pattern |
| PNL-02 | Revenue = actual_amount + discount_amount,排除已退款/取消订单(status=3,4) | Confirmed from dashboard_activity.go:209; status=2 filter is the correct paid-only gate |
| PNL-03 | Game-pass 订单通过 finance.IsGamePassOrder 三条件检测,与现金收入严格互斥 | finance.IsGamePassOrder verified in profit_metrics.go:43-51; 3 conditions documented |
| PNL-04 | Game-pass 订单收入通过 finance.ComputeGamePassValue 计算(draw_count × activity_price) | finance.ComputeGamePassValue verified in profit_metrics.go:53-58 |
| PNL-05 | Prize cost 通过 finance.ComputePrizeCostWithMultiplier 计算,包含道具卡倍率 | finance.ComputePrizeCostWithMultiplier verified in profit_metrics.go:67-73 |
| PNL-06 | Profit 通过 finance.ComputeProfit 计算,返回 int64 分 + float64 利润率 | finance.ComputeProfit verified in profit_metrics.go:75-81 |
| PNL-07 | 排除已作废库存(remark LIKE '%void%' 或 status=2)不计入成本 | Pattern in dashboard_activity.go:248-249; status IN (1,3) + remark NOT LIKE '%void%' |
| PNL-08 | 兼容 order_id=0 或 NULL 的历史数据(不受订单状态过滤影响) | Pattern in dashboard_activity.go:251; OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL |
| DIM-01 | QueryUserProfitLoss 接收 []int64 用户ID,空切片=统计全部用户 | GORM WHERE IN with empty slice guard; empty = omit WHERE clause |
| DIM-02 | QueryActivityProfitLoss 接收 []int64 活动ID,空切片=统计全部活动 | Same pattern as DIM-01 |
| DIM-03 | 时间范围过滤使用 *time.Time(nil=不限),不使用零值作哨兵 | Confirmed; *time.Time pointer pattern is idiomatic in this codebase |
| DIM-04 | AssetType 可选过滤,nil/All(0)=统计全部资产类型 | AssetTypeAll=0 as zero value is natural Go default |
| RET-01 | ProfitLossResult 包含汇总数据:总收入、总成本、净盈亏、利润率 | Struct fields verified against finance.ComputeProfit return values |
| RET-03 | 所有金额以 int64 分为单位,不使用 float64 存储金额 | Codebase-wide convention confirmed; only profit_rate is float64 |
| AST-01 | 定义 AssetType 枚举:Points(1)、Coupon(2)、ItemCard(3)、Product(4)、Fragment(5)、All(0) | Clean iota-style const block; All=0 as zero value |
| QUA-01 | 新函数放在 internal/service/finance/ 包下 | Package already exists with profit_metrics.go |
| QUA-02 | Service 构造器仅注入 DbR(读库),包内不出现 GetDbW() 调用 | Pattern from user.go New() func; finance service omits writeDB field entirely |
| QUA-03 | 每个 Scan() 调用必须检查 .Error 并返回错误,不静默吞掉 | Critical pattern; dashboard had this bug; new code must not repeat it |
| QUA-04 | 复用现有 finance.* 工具函数,不重复实现 | All 6 functions verified in profit_metrics.go; reuse confirmed |
| QUA-05 | 使用 fan-out + in-memory merge 查询模式 | Pattern from dashboard_activity.go; multiple Scan() calls merged via map[int64]*item |
| </phase_requirements> |
Summary
This phase adds two new service-layer functions — QueryUserProfitLoss and QueryActivityProfitLoss — to the existing internal/service/finance/ package. The codebase already has all the building blocks: six tested pure functions (profit_metrics.go), established fan-out query patterns (dashboard_activity.go), a SQLite test infrastructure, and clear conventions for constructor injection and error handling.
The key insight from reviewing the existing dashboard code is that the new service functions are simpler than the dashboard handlers because D-01 locks the 1:1 order-to-activity rule, eliminating the two-level subquery revenue proration that the dashboard requires. Revenue for the user dimension is a direct SUM(actual_amount + discount_amount) on the user's orders; for the activity dimension it is a direct SUM filtered to that activity's orders.
Cost still requires the same multi-join pattern as the dashboard: user_inventory joined to orders (for refund exclusion), activity_reward_settings and products (for price fallback chain per D-09 — note: D-09 says value_cents is the single source of truth, so the fallback chain simplifies to just user_inventory.value_cents), and system_item_cards (for multiplier). Points cost requires a separate scan on user_points_ledger converted via the points.exchange_rate system config. Coupon cost requires a scan on user_coupon_ledger for deduction amounts.
Primary recommendation: Implement as five new files in internal/service/finance/: service.go (interface + constructor), types.go (params, result structs, AssetType enum), query_user.go, query_activity.go, and service_test.go. Each query file executes 3-4 fan-out scans and merges in Go using the established map pattern.
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| gorm.io/gorm | 1.25.9 | ORM query execution | Already in project; .Table().Select().Scan() pattern used throughout |
| go.uber.org/zap (via logger.CustomLogger) | 1.26.0 | Structured error logging | Project-standard logger interface; injected via constructor |
| gorm.io/driver/sqlite | 1.4.3 | In-memory test DB | NewSQLiteRepoForTest() already exists in testrepo_sqlite.go |
| github.com/stretchr/testify | 1.11.1 | Test assertions | Project-standard test library |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| bindbox-game/internal/pkg/points | local | Points ↔ cents conversion | When computing points cost in cents; PointsToCents(pts, rate) |
| bindbox-game/internal/service/finance | local (same pkg) | Reusable finance primitives | All 6 functions from profit_metrics.go |
No new dependencies required. All libraries are in go.mod.
Architecture Patterns
Recommended Project Structure
internal/service/finance/
├── profit_metrics.go (EXISTING — pure business logic, no DB)
├── profit_metrics_test.go (EXISTING — pure unit tests)
├── service.go (NEW — Service interface + New() constructor)
├── types.go (NEW — AssetType enum, param structs, result types)
├── query_user.go (NEW — QueryUserProfitLoss scan logic, 3-4 Scan calls)
├── query_activity.go (NEW — QueryActivityProfitLoss scan logic, 3-4 Scan calls)
└── service_test.go (NEW — integration tests using NewSQLiteRepoForTest())
Pattern 1: Service Constructor (Read-Only DB Injection)
What: Constructor injects only the read replica *gorm.DB; the finance service struct has no writeDB field.
When to use: Always — QUA-02 mandates no GetDbW() in this package.
// Source: internal/service/user/user.go (constructor pattern reference)
package finance
import (
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"gorm.io/gorm"
)
type Service interface {
QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error)
QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error)
}
type service struct {
logger logger.CustomLogger
dbR *gorm.DB // read replica only — never use for writes
}
func New(l logger.CustomLogger, db mysql.Repo) Service {
return &service{
logger: l,
dbR: db.GetDbR(),
}
}
Pattern 2: Fan-Out + In-Memory Merge
What: Execute N independent Scan() calls (one per data source), then merge results in Go using map[int64]*ProfitLossDetail.
When to use: Any query requiring data from multiple tables that have 1-to-many relationships (avoids Cartesian product JOINs).
// Source: internal/api/admin/dashboard_activity.go (fan-out pattern reference)
// Step 1: revenue scan
type revenueRow struct {
DimensionID int64
TotalRevenue int64
TotalGamePassValue int64
}
var revenueRows []revenueRow
if err := db.Table(model.TableNameOrders).
Select(`
orders.user_id as dimension_id,
SUM(CASE WHEN source_type = 4 OR order_no LIKE 'GP%' OR (actual_amount = 0 AND remark LIKE '%use_game_pass%')
THEN 0
ELSE actual_amount + discount_amount
END) as total_revenue
`).
Where("orders.status = ?", 2).
Group("orders.user_id").
Scan(&revenueRows).Error; err != nil {
return nil, fmt.Errorf("revenue scan failed: %w", err)
}
// Step 2: cost scan (separate query)
type costRow struct {
DimensionID int64
TotalCost int64
}
var costRows []costRow
if err := db.Table(model.TableNameUserInventory).
Select(`
user_inventory.user_id as dimension_id,
SUM(user_inventory.value_cents) as total_cost
`).
Where("user_inventory.status IN ?", []int{1, 3}).
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2).
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Group("user_inventory.user_id").
Scan(&costRows).Error; err != nil {
return nil, fmt.Errorf("cost scan failed: %w", err)
}
// Step 3: merge in Go
resultMap := make(map[int64]*ProfitLossDetail)
for _, r := range revenueRows {
resultMap[r.DimensionID] = &ProfitLossDetail{
UserID: r.DimensionID,
Revenue: r.TotalRevenue,
}
}
for _, c := range costRows {
if item, ok := resultMap[c.DimensionID]; ok {
item.Cost = c.TotalCost
}
}
// Step 4: apply finance functions
for _, item := range resultMap {
item.Profit, item.ProfitRate = ComputeProfit(item.Revenue, item.Cost)
}
Pattern 3: Optional Parameter Filtering
What: Build up the GORM query conditionally; only add WHERE clauses when params are non-nil/non-empty.
When to use: All query functions in this service — DIM-01 through DIM-04.
// Source: established codebase pattern
func (s *service) buildBaseQuery(ctx context.Context, params UserProfitLossParams) *gorm.DB {
db := s.dbR.WithContext(ctx).Table(model.TableNameOrders)
// Empty slice = no filter (all records)
if len(params.UserIDs) > 0 {
db = db.Where("orders.user_id IN ?", params.UserIDs)
}
if params.StartTime != nil {
db = db.Where("orders.created_at >= ?", *params.StartTime)
}
if params.EndTime != nil {
db = db.Where("orders.created_at <= ?", *params.EndTime)
}
return db
}
Pattern 4: Points Cost Resolution
What: Read points.exchange_rate from system_configs, then convert points deductions from user_points_ledger to cents.
When to use: When computing points cost contribution (cost data from user_points_ledger).
// Source: internal/service/user/points_convert.go (getExchangeRate pattern)
func (s *service) getPointsExchangeRate(ctx context.Context) int64 {
var cfg model.SystemConfigs
if err := s.dbR.WithContext(ctx).
Where("config_key = ?", "points.exchange_rate").
First(&cfg).Error; err != nil {
return 1 // default: 1 yuan = 1 point
}
var rate int64
_, _ = fmt.Sscanf(cfg.ConfigValue, "%d", &rate)
if rate <= 0 {
return 1
}
return rate
}
// Convert points to cents: cents = points * 100 / rate
// Source: internal/pkg/points/convert.go PointsToCents()
pointsCostCents := points.PointsToCents(totalPointsDeducted, float64(exchangeRate))
Pattern 5: CAST(AS SIGNED) for Division SUM
What: Wrap any SUM expression containing division with CAST(... AS SIGNED) to prevent MySQL returning Decimal type.
When to use: Any SQL aggregation involving division in SUM.
Note: D-09 locks user_inventory.value_cents as the single source of truth for inventory cost, so the fallback COALESCE chain from the dashboard is NOT used. The cost formula simplifies to SUM(user_inventory.value_cents * multiplier / 1000) which still requires CAST.
// Source: internal/api/admin/dashboard_activity.go:237 (CAST pattern)
// MySQL returns DECIMAL for SUM(x * y / z) — must cast to SIGNED for int64 scan
Select(`
CAST(SUM(
user_inventory.value_cents
* GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000)
/ 1000
) AS SIGNED) as total_cost
`)
Pattern 6: Game-Pass Revenue Calculation
What: Use IsGamePassOrder() to classify orders. Game-pass revenue = draw_count × activity_price. Cash revenue = actual_amount + discount_amount. These are mutually exclusive (D-03).
When to use: Every revenue scan in both query functions.
Key simplification vs. dashboard: Since D-01 establishes 1:1 order-to-activity, there is NO need for the draw-count proration subquery that the dashboard uses. Revenue is directly attributable to the order's user/activity.
// For user dimension: classify per-order in Go after scanning raw fields
// Scan raw fields: source_type, order_no, actual_amount, discount_amount, remark, draw_count, activity_price
// Then call: ClassifyOrderSpending(sourceType, orderNo, actualAmount, discountAmount, remark, gpValue)
// Sum breakdown.Total as dimension revenue
Anti-Patterns to Avoid
- Mega-JOIN across all tables: Produces Cartesian products. Use fan-out separate Scan calls instead.
- Scanning division-SUM into int64 without CAST: Returns zero silently (MySQL Decimal → int64 mismatch).
- Skipping
.Errorcheck on Scan(): Silent wrong data. Every Scan must check error. - Using GetDbW() in this package: Violates QUA-02 and adds load to write master.
- Using
time.Time{}zero value as "no filter" sentinel: Use*time.Time; nil = no filter. - Passing empty slice to
WHERE IN (?): GORM generates invalid SQL. Guard withif len(ids) > 0before adding the WHERE clause. - Re-implementing game-pass classification logic in SQL CASE expressions: Duplicates
IsGamePassOrder()and diverges from the canonical rule. Scan raw fields, classify in Go. - COALESCE fallback chain for value_cents: D-09 locks
user_inventory.value_centsas single truth source — do NOT use the dashboard'sCOALESCE(NULLIF(value_cents,0), price_snapshot_cents, products.price, 0).
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Game-pass order classification | Custom CASE expression in SQL or new Go function | finance.IsGamePassOrder() |
Three conditions; already tested; must stay in sync across codebase |
| Game-pass value calculation | drawCount * price inline everywhere |
finance.ComputeGamePassValue() |
Guards against zero/negative inputs |
| Prize cost with multiplier | Custom multiplication in query | finance.ComputePrizeCostWithMultiplier() |
Handles multiplier normalization (GREATEST/default 1000) |
| Profit + profit rate calculation | revenue - cost inline |
finance.ComputeProfit() |
Handles zero-revenue edge case (avoids division by zero) |
| Points-to-cents conversion | Custom formula | points.PointsToCents(pts, rate) |
Handles rounding via math.Round; tested |
| Exchange rate lookup | Hardcode or re-implement | Pattern from user.getExchangeRate() |
Reads from system_configs table with safe default |
| Test database | Real MySQL connection | mysql.NewSQLiteRepoForTest() |
In-memory, zero-config, existing infrastructure |
Key insight: The internal/service/finance/ package already contains the entire mathematical foundation. This phase is a database query layer on top, not a business logic reimplementation.
Common Pitfalls
Pitfall 1: MySQL SUM with Division Returns Decimal (Silent Zero)
What goes wrong: SUM(value_cents * multiplier / 1000) returns Decimal type in MySQL. GORM scan into int64 silently produces 0. Cost appears as 0 even with data.
Why it happens: MySQL promotes arithmetic involving division to Decimal to preserve fractional precision. GORM does not coerce types.
How to avoid: Wrap the entire SUM expression with CAST(... AS SIGNED). Applies specifically to the multiplier cost calculation.
Warning signs: Cost fields are uniformly 0 across all activities/users despite inventory data existing.
Pitfall 2: Empty Slice in WHERE IN Produces Invalid SQL
What goes wrong: db.Where("user_id IN ?", []int64{}) generates WHERE user_id IN () — invalid SQL that returns error or empty result instead of all records.
Why it happens: GORM does not guard against empty slice inputs.
How to avoid: Always check len(ids) > 0 before adding the WHERE clause. Empty slice means "all records" per D-07 — do not add the filter at all.
Warning signs: Function returns empty result or SQL error when called with no IDs.
Pitfall 3: Game-Pass and Cash Revenue Double-Counted
What goes wrong: Including both actual_amount + discount_amount AND game-pass value for the same order. Game-pass orders have actual_amount = 0, so their coupon-based revenue is 0, but their game-pass value is not — adding both produces correct total by accident, but the classification is wrong and subtotals diverge from dashboard.
Why it happens: Treating all orders uniformly in a single SUM.
How to avoid: Scan both raw order fields AND activity price/draw_count. Classify per-order in Go with ClassifyOrderSpending(). The mutual exclusion is enforced by the function.
Warning signs: SpendingPaidCoupon and SpendingGamePass are both non-zero for the same order-level scan.
Pitfall 4: Refunded Order Inventory Counted as Cost
What goes wrong: user_inventory rows exist for prizes awarded from subsequently-refunded orders. Counting them inflates cost while excluding their revenue.
Why it happens: Inventory is created on award (before refund window). Refunds update orders.status to 4, not delete inventory.
How to avoid: Always join orders and filter (orders.status = 2 OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL). The legacy escape hatch (order_id = 0 OR NULL) is mandatory for old data compatibility (PNL-08).
Warning signs: Platform appears to have given away prizes for free; test with a refunded order shows non-zero cost.
Pitfall 5: Silently Ignored Scan Errors
What goes wrong: If a Scan fails (schema mismatch, DB failover), the result struct stays at zero values. No error is returned. The P&L appears correct (all zeros) rather than failing.
Why it happens: GORM method chaining makes it easy to omit .Error check.
How to avoid: Every Scan must be: if err := db...Scan(&result).Error; err != nil { return nil, fmt.Errorf("...: %w", err) }. This is QUA-03.
Warning signs: Function returns zero P&L with nil error even when data exists; test with a deliberately broken query.
Pitfall 6: SQLite Test Incompatibilities
What goes wrong: Tests using NewSQLiteRepoForTest() fail because SQLite does not support:
CAST(... AS SIGNED)— useCAST(... AS INTEGER)in test SQL or compute in GoGREATEST()MySQL function — not available in SQLiteLIKE 'GP%'may behave differently in edge cases
Why it happens: Integration tests use SQLite for speed/simplicity but production uses MySQL.
How to avoid: Keep game-pass classification in Go (scan raw fields, call IsGamePassOrder() in Go). Keep multiplier application in Go (scan raw value_cents and multiplier_x1000, call ComputePrizeCostWithMultiplier() in Go). Only perform non-division aggregations in SQL for tests.
Warning signs: Tests pass locally with Go-layer classification but fail when logic is moved into SQL CASE expressions.
Code Examples
Verified patterns from codebase analysis:
Service Constructor
// Source: internal/service/user/user.go:100-102 (reference pattern)
// Finance service omits writeDB entirely (QUA-02)
func New(l logger.CustomLogger, db mysql.Repo) Service {
return &service{
logger: l,
dbR: db.GetDbR(),
}
}
Param Structs and Types
// Source: CONTEXT.md D-04, D-07; STACK.md pattern
type AssetType int
const (
AssetTypeAll AssetType = 0 // zero value = all types
AssetTypePoints AssetType = 1
AssetTypeCoupon AssetType = 2
AssetTypeItemCard AssetType = 3
AssetTypeProduct AssetType = 4
AssetTypeFragment AssetType = 5
)
type UserProfitLossParams struct {
UserIDs []int64 // empty = all users (DIM-01)
AssetType AssetType // 0 = all types (DIM-04)
StartTime *time.Time // nil = no lower bound (DIM-03)
EndTime *time.Time // nil = no upper bound (DIM-03)
}
type ActivityProfitLossParams struct {
ActivityIDs []int64 // empty = all activities (DIM-02)
AssetType AssetType // 0 = all types (DIM-04)
StartTime *time.Time // nil = no lower bound (DIM-03)
EndTime *time.Time // nil = no upper bound (DIM-03)
}
Result Structs
// Source: CONTEXT.md D-05, D-06; REQUIREMENTS.md RET-01, RET-03
type ProfitLossDetail struct {
UserID int64 // populated for user dimension
ActivityID int64 // populated for activity dimension
Revenue int64 // fen (RET-03: int64 only)
Cost int64 // fen
Profit int64 // fen
ProfitRate float64 // ratio (only field that uses float64)
}
type ProfitLossResult struct {
TotalRevenue int64 // fen (RET-01)
TotalCost int64 // fen
TotalProfit int64 // fen
ProfitRate float64 // ratio
Details []ProfitLossDetail // per-user or per-activity (D-06)
Breakdown []interface{} // Phase 2: empty slice placeholder (deferred)
}
Revenue Query (User Dimension, No Proration)
// Source: CONTEXT.md D-01, D-03; simplification vs dashboard_activity.go
// No two-level subquery needed — 1:1 order-to-activity (D-01)
type userRevenueRow struct {
UserID int64
CashRevenue int64 // actual_amount + discount_amount for non-game-pass orders
GamePassDraws int64 // draw count for game-pass orders
ActivityPriceDraw int64 // unit price of the activity (for game-pass value calc)
}
// NOTE: Game-pass value = GamePassDraws × ActivityPriceDraw, computed in Go
// using ComputeGamePassValue() — not computed in SQL to maintain SQLite test compat
Cost Query (Inventory, User Dimension)
// Source: dashboard_activity.go:234-263 (adapted per D-09: value_cents only, no fallback chain)
type userCostRow struct {
UserID int64
TotalCostCents int64 // CAST(SUM(value_cents * multiplier / 1000) AS SIGNED)
}
// Note: CAST required for division-containing SUM (Pitfall 1)
// Note: status IN (1,3) + remark NOT LIKE '%void%' + legacy order_id=0 guard (PNL-07, PNL-08)
Error Handling Pattern
// Source: PITFALLS.md Pitfall 4; QUA-03
var rows []revenueRow
if err := s.dbR.WithContext(ctx).
Table(model.TableNameOrders).
Select("...").
Where("orders.status = ?", 2).
Group("orders.user_id").
Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("QueryUserProfitLoss revenue scan: %w", err)
}
Points Cost Resolution
// Source: internal/service/user/points_convert.go:13-25
// Read exchange rate from system_configs, convert points ledger deductions to cents
var pointRows []struct {
UserID int64
TotalPoints int64 // SUM of negative point changes = cost
}
// After scan:
rate := s.getPointsExchangeRate(ctx) // reads "points.exchange_rate" key
for _, r := range pointRows {
costCents := points.PointsToCents(r.TotalPoints, float64(rate))
resultMap[r.UserID].Cost += costCents
}
Test Setup
// Source: internal/repository/mysql/testrepo_sqlite.go
func TestQueryUserProfitLoss(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
require.NoError(t, err)
// Create tables with AutoMigrate
db := repo.GetDbR()
require.NoError(t, db.AutoMigrate(&model.Orders{}, &model.UserInventory{}, ...))
// Seed test data
// ...
svc := New(logger.NewCustomLogger(nil, logger.WithOutputInConsole()), repo)
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{})
require.NoError(t, err)
// assert...
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Single mega-JOIN across orders + inventory + draw_logs | Fan-out separate Scan calls merged in Go | Dashboard v2 (already in codebase) | Eliminates Cartesian product; individual queries are independently testable |
| Revenue attributed by scanning orders directly in handler | Service-layer function with injected DB and typed params | This phase | Callers don't need to write SQL; consistent calculation across all endpoints |
| Dashboard handlers as source of truth for P&L numbers | finance.* utility functions + new service layer |
This phase | Decoupled from HTTP context; reusable from any caller |
| float64 scan for SUM-with-division | CAST(... AS SIGNED) + int64 scan | Dashboard bugfix (already in code) | Eliminates floating-point monetary rounding |
Deprecated/outdated:
- Fallback COALESCE chain for
value_cents— D-09 deprecates this in the new service (dashboard still uses it for backward compat; new service usesvalue_centsdirectly) - Inline game-pass CASE expressions in SQL — deprecated in favor of Go-layer classification via
IsGamePassOrder()
Open Questions
-
Points ledger: which
actionvalues represent cost deductions?- What we know:
user_points_ledger.actionincludesorder_deduct,refund_restore,signin,manual - What's unclear: Should only
order_deductactions count as cost? Or all negative-delta entries? - Recommendation: Filter on
action = 'order_deduct'ANDpoints < 0for cost. Refund restores (refund_restore) should cancel the cost — verify by checking if the net sum correctly cancels on refund.
- What we know:
-
Coupon cost: which
user_coupon_ledger.actionvalues represent platform cost?- What we know:
user_coupon_ledgerhaschange_amount(negative = deduction),order_id,action - What's unclear: Is
SUM(ABS(change_amount)) WHERE change_amount < 0the correct cost formula, or should we filter by action? - Recommendation: Sum all deductions (
change_amount < 0) for orders with status=2 (paid). Join to orders table to filter refunded orders.
- What we know:
-
Activity price for game-pass value: which field is authoritative?
- What we know: D-02 says
draw_count × activity_unit_price.activities.price_drawis the per-draw price used in the dashboard. - What's unclear: For the user dimension query, orders may span multiple activities. Does each order carry the activity's price at order time, or must we join to
activities? - Recommendation: Join
orders→activity_draw_logs→activity_issues→activitiesto getactivities.price_draw. This is the same join the dashboard uses for game-pass value (dashboard_activity.go:280-296).
- What we know: D-02 says
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Go testing + testify v1.11.1 |
| Config file | none — standard go test |
| Quick run command | go test -v ./internal/service/finance/... |
| Full suite command | make test (runs go test -v --cover ./internal/...) |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| PNL-01 | Params struct accepted with all nil/empty fields | unit | go test -run TestQueryUserProfitLoss_EmptyParams ./internal/service/finance/ |
❌ Wave 0 |
| PNL-02 | Refunded orders excluded from revenue | integration | go test -run TestQueryUserProfitLoss_RefundedOrderExcluded ./internal/service/finance/ |
❌ Wave 0 |
| PNL-03 | Game-pass orders classified correctly, mutually exclusive with cash | unit | go test -run TestClassifyOrderSpending ./internal/service/finance/ |
✅ profit_metrics_test.go |
| PNL-04 | Game-pass value = draw_count × activity_price | unit | go test -run TestComputeGamePassValue ./internal/service/finance/ |
✅ profit_metrics_test.go |
| PNL-05 | Prize cost includes item-card multiplier | unit | go test -run TestComputePrizeCostWithMultiplier ./internal/service/finance/ |
✅ profit_metrics_test.go |
| PNL-06 | Profit and profit_rate computed correctly | unit | go test -run TestProfit ./internal/service/finance/ |
✅ profit_metrics_test.go |
| PNL-07 | Voided inventory excluded from cost | integration | go test -run TestQueryUserProfitLoss_VoidedInventoryExcluded ./internal/service/finance/ |
❌ Wave 0 |
| PNL-08 | Legacy order_id=0 inventory included in cost | integration | go test -run TestQueryUserProfitLoss_LegacyZeroOrderID ./internal/service/finance/ |
❌ Wave 0 |
| DIM-01 | Empty UserIDs returns all users | integration | go test -run TestQueryUserProfitLoss_AllUsers ./internal/service/finance/ |
❌ Wave 0 |
| DIM-02 | Empty ActivityIDs returns all activities | integration | go test -run TestQueryActivityProfitLoss_AllActivities ./internal/service/finance/ |
❌ Wave 0 |
| DIM-03 | *time.Time nil = no time filter | unit | go test -run TestBuildBaseQuery_NilTime ./internal/service/finance/ |
❌ Wave 0 |
| DIM-04 | AssetType=0 returns all types | unit | go test -run TestQueryUserProfitLoss_AllAssetTypes ./internal/service/finance/ |
❌ Wave 0 |
| RET-01 | Result includes TotalRevenue, TotalCost, TotalProfit, ProfitRate | integration | go test -run TestQueryUserProfitLoss_ResultShape ./internal/service/finance/ |
❌ Wave 0 |
| RET-03 | All monetary fields are int64 | compile-time | go build ./internal/service/finance/ |
❌ Wave 0 |
| AST-01 | AssetType constants defined with correct values | unit | go test -run TestAssetTypeConstants ./internal/service/finance/ |
❌ Wave 0 |
| QUA-01 | New files in correct package | compile-time | go build ./internal/service/finance/ |
❌ Wave 0 |
| QUA-02 | No GetDbW() in finance package | static | grep -r "GetDbW" ./internal/service/finance/ must return empty |
❌ Wave 0 |
| QUA-03 | All Scan() errors checked | code review + test | go test -run TestQueryUserProfitLoss_ScanError ./internal/service/finance/ |
❌ Wave 0 |
| QUA-04 | finance.* utilities called, not reimplemented | code review | grep -r "IsGamePassOrder|ComputeProfit" ./internal/service/finance/query_*.go |
❌ Wave 0 |
| QUA-05 | Fan-out pattern: multiple Scan calls, merge in Go | code review | grep -c "Scan" ./internal/service/finance/query_user.go should be >= 3 |
❌ Wave 0 |
Sampling Rate
- Per task commit:
go test -v ./internal/service/finance/... - Per wave merge:
make test - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
internal/service/finance/service_test.go— all integration tests using SQLiteRepoForTestinternal/service/finance/service.go— Service interface + New() constructorinternal/service/finance/types.go— AssetType enum, param structs, result types
(Existing profit_metrics_test.go covers PNL-03, PNL-04, PNL-05, PNL-06 — no gaps for those)
Sources
Primary (HIGH confidence)
internal/service/finance/profit_metrics.go— All 6 reusable finance functions verified in sourceinternal/service/finance/profit_metrics_test.go— Existing test patterns confirmedinternal/api/admin/dashboard_activity.go— Fan-out query pattern, CAST(AS SIGNED), refund exclusion, game-pass stats, void exclusion confirmed at lines 146-309internal/repository/mysql/mysql.go— Repo interface, GetDbR()/GetDbW() confirmedinternal/repository/mysql/testrepo_sqlite.go— NewSQLiteRepoForTest() confirmedinternal/service/user/user.go— Service interface + constructor pattern confirmed at lines 93-102internal/service/user/points_convert.go— getExchangeRate pattern confirmedinternal/pkg/points/convert.go— PointsToCents/CentsToPoints confirmedinternal/repository/mysql/model/user_inventory.gen.go— UserInventory schema: value_cents, status, remark, order_id, activity_id fieldsinternal/repository/mysql/model/user_points_ledger.gen.go— Points ledger schema confirmedinternal/repository/mysql/model/user_coupon_ledger.gen.go— Coupon ledger schema: change_amount, order_id, action fields.planning/research/PITFALLS.md— 6 pitfalls with codebase evidence.planning/research/STACK.md— Query patterns and SQLite compat notes.planning/research/FEATURES.md— Feature prioritization matrix
Secondary (MEDIUM confidence)
.planning/phases/01-core-pnl-functions/1-CONTEXT.md— All locked decisions (D-01 through D-11).planning/REQUIREMENTS.md— Requirement definitions
Metadata
Confidence breakdown:
- Standard stack: HIGH — entire stack is existing, verified from source files
- Architecture patterns: HIGH — all patterns lifted directly from existing codebase implementations
- Pitfalls: HIGH — derived from existing bug-fix comments in dashboard code plus Go/MySQL behavior
- Open questions: MEDIUM — points/coupon cost query specifics require validation against schema and business intent during implementation
Research date: 2026-03-21 Valid until: 2026-06-21 (stable Go/GORM stack; schema changes would invalidate)