35 KiB
Raw Blame History

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: 两个独立的参数结构体:UserProfitLossParamsActivityProfitLossParams(不共享)
  • 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.Timenil=不限),不使用零值作哨兵 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

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 .Error check 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 with if len(ids) > 0 before 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_cents as single truth source — do NOT use the dashboard's COALESCE(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) — use CAST(... AS INTEGER) in test SQL or compute in Go
  • GREATEST() MySQL function — not available in SQLite
  • LIKE '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 uses value_cents directly)
  • Inline game-pass CASE expressions in SQL — deprecated in favor of Go-layer classification via IsGamePassOrder()

Open Questions

  1. Points ledger: which action values represent cost deductions?

    • What we know: user_points_ledger.action includes order_deduct, refund_restore, signin, manual
    • What's unclear: Should only order_deduct actions count as cost? Or all negative-delta entries?
    • Recommendation: Filter on action = 'order_deduct' AND points < 0 for cost. Refund restores (refund_restore) should cancel the cost — verify by checking if the net sum correctly cancels on refund.
  2. Coupon cost: which user_coupon_ledger.action values represent platform cost?

    • What we know: user_coupon_ledger has change_amount (negative = deduction), order_id, action
    • What's unclear: Is SUM(ABS(change_amount)) WHERE change_amount < 0 the 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.
  3. Activity price for game-pass value: which field is authoritative?

    • What we know: D-02 says draw_count × activity_unit_price. activities.price_draw is 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 ordersactivity_draw_logsactivity_issuesactivities to get activities.price_draw. This is the same join the dashboard uses for game-pass value (dashboard_activity.go:280-296).

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 SQLiteRepoForTest
  • internal/service/finance/service.go — Service interface + New() constructor
  • internal/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 source
  • internal/service/finance/profit_metrics_test.go — Existing test patterns confirmed
  • internal/api/admin/dashboard_activity.go — Fan-out query pattern, CAST(AS SIGNED), refund exclusion, game-pass stats, void exclusion confirmed at lines 146-309
  • internal/repository/mysql/mysql.go — Repo interface, GetDbR()/GetDbW() confirmed
  • internal/repository/mysql/testrepo_sqlite.go — NewSQLiteRepoForTest() confirmed
  • internal/service/user/user.go — Service interface + constructor pattern confirmed at lines 93-102
  • internal/service/user/points_convert.go — getExchangeRate pattern confirmed
  • internal/pkg/points/convert.go — PointsToCents/CentsToPoints confirmed
  • internal/repository/mysql/model/user_inventory.gen.go — UserInventory schema: value_cents, status, remark, order_id, activity_id fields
  • internal/repository/mysql/model/user_points_ledger.gen.go — Points ledger schema confirmed
  • internal/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)