Adds GET /marketing/wallet-transactions/:id/trace (docs/prd-point-coin.md F7, §8.1, PC-404). From any ledger row of the organization, the trace lists the lots a debit took from, with how much it took from each, or the lots a credit created. Each lot is followed back through origin_lot_id, across transfers, exchanges and refunds, to the lot an EARN, ADJUSTMENT or MIGRATION first created. Every step shows the lot and the row that created it, with the real name of the customer it belongs to, so the example of §8 (A sends 120 to B, B pays 30) leads from B's payment to A's order #ORD-1. Lots are loaded a generation at a time, and a chain stops at 100 steps or at a lot it has already seen, which only bad data could cause. A row of another organization answers 404. The dashboard's wallet view now builds its lots with the same helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
5.3 KiB
Go
181 lines
5.3 KiB
Go
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
|
|
}
|