package processor import ( "context" "time" "github.com/google/uuid" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) // walletTraceMaxDepth bounds how far back a chain is followed. Every step is a // transfer, exchange or refund of the step before, so real chains are short; the // bound only guards against a loop in bad data. const walletTraceMaxDepth = 100 // WalletTraceProcessor traces a ledger row lot by lot for the dashboard // (docs/prd-point-coin.md F7, ยง8.1). It only reads. type WalletTraceProcessor struct { repo repository.WalletTraceRepository now func() time.Time } func NewWalletTraceProcessor(repo repository.WalletTraceRepository) *WalletTraceProcessor { return &WalletTraceProcessor{repo: repo, now: time.Now} } type walletTraceStart struct { lotID uuid.UUID amount int64 } // Trace follows a row of the organization's ledger: a debit through the lots it took // from, a credit through the lots it created, and each lot back through origin_lot_id // to the lot that first brought the balance in. A row of another organization is // repository.ErrWalletTransactionNotFound. func (p *WalletTraceProcessor) Trace(ctx context.Context, organizationID, transactionID uuid.UUID) (*models.AdminWalletTrace, error) { row, err := p.repo.GetTransaction(ctx, organizationID, transactionID) if err != nil { return nil, err } var starts []walletTraceStart if row.Amount < 0 { allocations, err := p.repo.ListAllocations(ctx, row.ID) if err != nil { return nil, err } for _, a := range allocations { starts = append(starts, walletTraceStart{lotID: a.LotID, amount: a.Amount}) } } else { created, err := p.repo.ListLotsBySource(ctx, row.ID) if err != nil { return nil, err } for _, lot := range created { starts = append(starts, walletTraceStart{lotID: lot.ID, amount: lot.OriginalAmount}) } } lots, err := p.loadChains(ctx, starts) if err != nil { return nil, err } sourceIDs := make([]uuid.UUID, 0, len(lots)) customerIDs := []uuid.UUID{row.CustomerID} for _, lot := range lots { sourceIDs = append(sourceIDs, lot.SourceTransactionID) customerIDs = append(customerIDs, lot.CustomerID) } sourceRows, err := p.repo.GetTransactions(ctx, uniqueIDs(sourceIDs)) if err != nil { return nil, err } sources := make(map[uuid.UUID]entities.WalletTransaction, len(sourceRows)) for _, s := range sourceRows { sources[s.ID] = s } names, err := p.repo.CustomerNames(ctx, uniqueIDs(customerIDs)) if err != nil { return nil, err } now := p.now() trace := &models.AdminWalletTrace{ Transaction: walletTraceRow(*row, names), Lots: make([]models.AdminWalletTraceLot, 0, len(starts)), } for _, start := range starts { item := models.AdminWalletTraceLot{Amount: start.amount, Chain: []models.AdminWalletTraceStep{}} visited := map[uuid.UUID]bool{} for id := &start.lotID; id != nil && !visited[*id]; { lot, ok := lots[*id] if !ok { break } visited[*id] = true item.Chain = append(item.Chain, models.AdminWalletTraceStep{ Lot: adminWalletLot(lot, now), Source: walletTraceRow(sources[lot.SourceTransactionID], names), }) id = lot.OriginLotID } trace.Lots = append(trace.Lots, item) } return trace, nil } // loadChains loads the starting lots and every lot they were carried over from, one // generation at a time. func (p *WalletTraceProcessor) loadChains(ctx context.Context, starts []walletTraceStart) (map[uuid.UUID]entities.WalletLot, error) { lots := map[uuid.UUID]entities.WalletLot{} pending := make([]uuid.UUID, 0, len(starts)) for _, s := range starts { pending = append(pending, s.lotID) } for depth := 0; len(pending) > 0 && depth < walletTraceMaxDepth; depth++ { found, err := p.repo.GetLots(ctx, uniqueIDs(pending)) if err != nil { return nil, err } pending = pending[:0] for _, lot := range found { lots[lot.ID] = lot } for _, lot := range found { if lot.OriginLotID != nil { if _, seen := lots[*lot.OriginLotID]; !seen { pending = append(pending, *lot.OriginLotID) } } } } return lots, nil } func walletTraceRow(row entities.WalletTransaction, names map[uuid.UUID]string) models.AdminWalletTraceRow { return models.AdminWalletTraceRow{ ID: row.ID, Customer: models.AdminWalletNamedRef{ID: row.CustomerID, Name: names[row.CustomerID]}, Currency: row.Currency, Type: row.Type, Amount: row.Amount, Description: row.Description, ReferenceType: row.ReferenceType, ReferenceID: row.ReferenceID, OutletID: row.OutletID, CreatedAt: row.CreatedAt, } } func adminWalletLot(lot entities.WalletLot, now time.Time) models.AdminWalletLot { return models.AdminWalletLot{ ID: lot.ID, Currency: lot.Currency, OriginalAmount: lot.OriginalAmount, RemainingAmount: lot.RemainingAmount, ExpiresAt: lot.ExpiresAt, Expired: lot.ExpiresAt != nil && !lot.ExpiresAt.After(now), SourceTransactionID: lot.SourceTransactionID, OriginLotID: lot.OriginLotID, CreatedAt: lot.CreatedAt, } } func uniqueIDs(ids []uuid.UUID) []uuid.UUID { seen := make(map[uuid.UUID]bool, len(ids)) out := make([]uuid.UUID, 0, len(ids)) for _, id := range ids { if !seen[id] { seen[id] = true out = append(out, id) } } return out }