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
|
||
|
|
}
|