Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)

This reverts commit 4e24f9bbb0.
This commit is contained in:
efrilm
2026-09-30 15:31:44 +07:00
parent 4e24f9bbb0
commit 582dc75543
195 changed files with 20058 additions and 2177 deletions
@@ -0,0 +1,180 @@
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
}