feat(loyalty): trace a wallet row lot by lot in the dashboard
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a18bb072f5
commit
8bf1d5c1a8
+3
-1
@@ -395,6 +395,7 @@ type processors struct {
|
|||||||
pointPaymentProcessor *processor.PointPaymentProcessor
|
pointPaymentProcessor *processor.PointPaymentProcessor
|
||||||
walletExchangeProcessor *processor.WalletExchangeProcessor
|
walletExchangeProcessor *processor.WalletExchangeProcessor
|
||||||
walletTransferProcessor *processor.WalletTransferProcessor
|
walletTransferProcessor *processor.WalletTransferProcessor
|
||||||
|
walletTraceProcessor *processor.WalletTraceProcessor
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||||
@@ -472,6 +473,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
|||||||
pointPaymentProcessor: pointPaymentProcessor,
|
pointPaymentProcessor: pointPaymentProcessor,
|
||||||
walletExchangeProcessor: walletExchangeProcessor,
|
walletExchangeProcessor: walletExchangeProcessor,
|
||||||
walletTransferProcessor: walletTransferProcessor,
|
walletTransferProcessor: walletTransferProcessor,
|
||||||
|
walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
|
||||||
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
|
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -602,7 +604,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
|||||||
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
|
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
|
||||||
expenseService: service.NewExpenseService(processors.expenseProcessor),
|
expenseService: service.NewExpenseService(processors.expenseProcessor),
|
||||||
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
|
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
|
||||||
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor),
|
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor, processors.walletTraceProcessor),
|
||||||
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo),
|
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo),
|
||||||
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor),
|
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor),
|
||||||
pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor),
|
pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor),
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
h := NewWalletAdminHandler(
|
h := NewWalletAdminHandler(
|
||||||
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm)),
|
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))),
|
||||||
validator.NewWalletValidator(),
|
validator.NewWalletValidator(),
|
||||||
)
|
)
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|||||||
@@ -83,3 +83,17 @@ func (h *WalletAdminHandler) AdjustCustomerWallet(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::AdjustCustomerWallet")
|
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::AdjustCustomerWallet")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TraceTransaction is GET /marketing/wallet-transactions/:id/trace.
|
||||||
|
func (h *WalletAdminHandler) TraceTransaction(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
transactionID, ok := parseUUIDParam(c, "id", "WalletAdminHandler::TraceTransaction")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response := h.walletAdminService.TraceTransaction(ctx, appcontext.FromGinContext(ctx), transactionID)
|
||||||
|
if response.HasErrors() {
|
||||||
|
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::TraceTransaction -> service call failed")
|
||||||
|
}
|
||||||
|
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::TraceTransaction")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminWalletTrace is GET /marketing/wallet-transactions/:id/trace
|
||||||
|
// (docs/prd-point-coin.md F7, §8.1): the lots a ledger row took from or created, and
|
||||||
|
// where each of them came from, lot by lot, back to the lot that first brought the
|
||||||
|
// balance into a wallet. It follows balance across transfers, exchanges and refunds.
|
||||||
|
type AdminWalletTrace struct {
|
||||||
|
Transaction AdminWalletTraceRow `json:"transaction"`
|
||||||
|
// One per lot the row took from (a debit) or created (a credit).
|
||||||
|
Lots []AdminWalletTraceLot `json:"lots"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminWalletTraceLot is how much of a row went through one lot, and that lot's chain.
|
||||||
|
type AdminWalletTraceLot struct {
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
// The lot itself first, then the lot it was carried over from, and so on. The
|
||||||
|
// last step is the lot an EARN, ADJUSTMENT or MIGRATION created.
|
||||||
|
Chain []AdminWalletTraceStep `json:"chain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminWalletTraceStep is one lot on a chain and the row that created it.
|
||||||
|
type AdminWalletTraceStep struct {
|
||||||
|
Lot AdminWalletLot `json:"lot"`
|
||||||
|
Source AdminWalletTraceRow `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminWalletTraceRow is a ledger row as a trace shows it, with the real name of the
|
||||||
|
// customer it belongs to.
|
||||||
|
type AdminWalletTraceRow struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Customer AdminWalletNamedRef `json:"customer"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ReferenceType string `json:"reference_type"`
|
||||||
|
ReferenceID uuid.UUID `json:"reference_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -69,17 +69,7 @@ func (p *WalletAdminProcessor) GetCustomerWallet(ctx context.Context, organizati
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, lot := range lots {
|
for _, lot := range lots {
|
||||||
result.Lots = append(result.Lots, models.AdminWalletLot{
|
result.Lots = append(result.Lots, adminWalletLot(lot, now))
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, total, err := p.query.ListTransactions(ctx, filter)
|
rows, total, err := p.query.ListTransactions(ctx, filter)
|
||||||
|
|||||||
@@ -155,3 +155,44 @@ func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) {
|
|||||||
return err
|
return err
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The example of §8 against Postgres: B's payment of 30 traces back to A's #ORD-1.
|
||||||
|
func TestWalletTrace_AgainstPostgres(t *testing.T) {
|
||||||
|
db, org, a, b := walletMoveDB(t)
|
||||||
|
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||||
|
txm := repository.NewTxManager(db)
|
||||||
|
settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{
|
||||||
|
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
|
||||||
|
}}
|
||||||
|
transfers := NewWalletTransferProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db), &movePinFake{good: "482913"}, wallet, txm, nil)
|
||||||
|
|
||||||
|
dec, jan := time.Now().Add(30*24*time.Hour), time.Now().Add(60*24*time.Hour)
|
||||||
|
ord1 := earn(a, 100, &dec)
|
||||||
|
ord1.Description = "Belanja #ORD-1"
|
||||||
|
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||||
|
if _, err := wallet.Credit(ctx, ord1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := wallet.Credit(ctx, earn(a, 50, &jan))
|
||||||
|
return err
|
||||||
|
}))
|
||||||
|
_, err := transfers.Transfer(context.Background(), a, sendPoints(120, "08"+b.String()[:10]), "482913", "trace", models.CustomerPinRequestInfo{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
var payment *WalletResult
|
||||||
|
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||||
|
payment, err = wallet.Debit(ctx, pay(b, 30))
|
||||||
|
return err
|
||||||
|
}))
|
||||||
|
|
||||||
|
trace, err := NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), org, payment.Transaction.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, trace.Lots, 1)
|
||||||
|
chain := trace.Lots[0].Chain
|
||||||
|
require.Len(t, chain, 2)
|
||||||
|
assert.Equal(t, constants.WalletTxTypeTransferIn, chain[0].Source.Type)
|
||||||
|
assert.Equal(t, "Anita", chain[1].Source.Customer.Name)
|
||||||
|
assert.Equal(t, ord1.ReferenceID, chain[1].Source.ReferenceID)
|
||||||
|
|
||||||
|
_, err = NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), uuid.New(), payment.Transaction.ID)
|
||||||
|
assert.ErrorIs(t, err, repository.ErrWalletTransactionNotFound)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package processor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
"apskel-pos-be/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// walletTraceRepoFake reads the fake wallet the way the trace repository reads the
|
||||||
|
// tables.
|
||||||
|
type walletTraceRepoFake struct{ e *walletMoveEnv }
|
||||||
|
|
||||||
|
func (f walletTraceRepoFake) GetTransaction(_ context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error) {
|
||||||
|
for _, tx := range f.e.repo.transactions {
|
||||||
|
if tx.ID == id && tx.OrganizationID == organizationID {
|
||||||
|
c := *tx
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, repository.ErrWalletTransactionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f walletTraceRepoFake) GetTransactions(_ context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error) {
|
||||||
|
var out []entities.WalletTransaction
|
||||||
|
for _, tx := range f.e.repo.transactions {
|
||||||
|
if containsID(ids, tx.ID) {
|
||||||
|
out = append(out, *tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f walletTraceRepoFake) ListAllocations(ctx context.Context, id uuid.UUID) ([]entities.WalletLotAllocation, error) {
|
||||||
|
return f.e.repo.ListAllocationsByTransaction(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f walletTraceRepoFake) ListLotsBySource(ctx context.Context, id uuid.UUID) ([]entities.WalletLot, error) {
|
||||||
|
return f.e.repo.ListLotsBySourceTransaction(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f walletTraceRepoFake) GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
|
||||||
|
return f.e.repo.GetLotsByIDs(ctx, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f walletTraceRepoFake) CustomerNames(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||||
|
names := map[uuid.UUID]string{}
|
||||||
|
for _, id := range ids {
|
||||||
|
if c, ok := f.e.customers.byID[id]; ok {
|
||||||
|
names[id] = c.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsID(ids []uuid.UUID, id uuid.UUID) bool {
|
||||||
|
for _, v := range ids {
|
||||||
|
if v == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRow(t *testing.T, e *walletMoveEnv, customerID uuid.UUID, txType string) *entities.WalletTransaction {
|
||||||
|
t.Helper()
|
||||||
|
for _, tx := range e.repo.transactions {
|
||||||
|
if tx.CustomerID == customerID && tx.Type == txType {
|
||||||
|
return tx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no %s row for %s", txType, customerID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The example of §8: A has 100 from #ORD-1 and 50 from #ORD-2, sends 120 to B, and B
|
||||||
|
// pays 30. Tracing B's payment leads to A's order #ORD-1.
|
||||||
|
func TestWalletTrace_PaymentLeadsBackToTheSendersOrder(t *testing.T) {
|
||||||
|
e := newWalletMoveEnv(t)
|
||||||
|
a := e.member("Anita", "081200005678")
|
||||||
|
b := e.member("Budi Santoso", "081234561234")
|
||||||
|
ord1 := earn(a, 100, e.at(30*24*time.Hour))
|
||||||
|
ord1.Description = "Belanja #ORD-1"
|
||||||
|
ord2 := earn(a, 50, e.at(60*24*time.Hour))
|
||||||
|
ord2.Description = "Belanja #ORD-2"
|
||||||
|
e.credit(t, ord1)
|
||||||
|
e.credit(t, ord2)
|
||||||
|
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
payment, err := e.p.Debit(e.ctx, pay(b, 30))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
p := NewWalletTraceProcessor(walletTraceRepoFake{e})
|
||||||
|
trace, err := p.Trace(e.ctx, e.org, payment.Transaction.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, constants.WalletTxTypePayment, trace.Transaction.Type)
|
||||||
|
assert.Equal(t, "Budi Santoso", trace.Transaction.Customer.Name)
|
||||||
|
require.Len(t, trace.Lots, 1)
|
||||||
|
assert.Equal(t, int64(30), trace.Lots[0].Amount)
|
||||||
|
chain := trace.Lots[0].Chain
|
||||||
|
require.Len(t, chain, 2)
|
||||||
|
assert.Equal(t, constants.WalletTxTypeTransferIn, chain[0].Source.Type)
|
||||||
|
assert.Equal(t, b, chain[0].Source.Customer.ID)
|
||||||
|
assert.Equal(t, constants.WalletTxTypeEarn, chain[1].Source.Type)
|
||||||
|
assert.Equal(t, "Anita", chain[1].Source.Customer.Name)
|
||||||
|
assert.Equal(t, "Belanja #ORD-1", chain[1].Source.Description)
|
||||||
|
assert.Equal(t, constants.WalletRefTypeOrder, chain[1].Source.ReferenceType)
|
||||||
|
assert.Equal(t, ord1.ReferenceID, chain[1].Source.ReferenceID)
|
||||||
|
assert.Nil(t, chain[1].Lot.OriginLotID, "the chain ends at the lot the order created")
|
||||||
|
assert.Equal(t, *chain[0].Lot.OriginLotID, chain[1].Lot.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) {
|
||||||
|
e := newWalletMoveEnv(t)
|
||||||
|
a := e.member("Anita", "081200005678")
|
||||||
|
b := e.member("Budi", "081234561234")
|
||||||
|
e.credit(t, earn(a, 100, e.at(time.Hour)))
|
||||||
|
e.credit(t, earn(a, 50, nil))
|
||||||
|
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
p := NewWalletTraceProcessor(walletTraceRepoFake{e})
|
||||||
|
|
||||||
|
// The sending side took from both of A's lots, which were A's own earnings.
|
||||||
|
out, err := p.Trace(e.ctx, e.org, findRow(t, e, a, constants.WalletTxTypeTransferOut).ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, out.Lots, 2)
|
||||||
|
assert.Equal(t, []int64{100, 20}, []int64{out.Lots[0].Amount, out.Lots[1].Amount})
|
||||||
|
for _, lot := range out.Lots {
|
||||||
|
require.Len(t, lot.Chain, 1)
|
||||||
|
assert.Equal(t, constants.WalletTxTypeEarn, lot.Chain[0].Source.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The receiving side created two lots, each carried over from one of A's.
|
||||||
|
in, err := p.Trace(e.ctx, e.org, findRow(t, e, b, constants.WalletTxTypeTransferIn).ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, in.Lots, 2)
|
||||||
|
for _, lot := range in.Lots {
|
||||||
|
require.Len(t, lot.Chain, 2)
|
||||||
|
assert.Equal(t, b, lot.Chain[0].Source.Customer.ID)
|
||||||
|
assert.Equal(t, a, lot.Chain[1].Source.Customer.ID)
|
||||||
|
assert.Equal(t, lot.Chain[1].Lot.ExpiresAt, lot.Chain[0].Lot.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalletTrace_OtherOrganizationsRowsAreNotFound(t *testing.T) {
|
||||||
|
e := newWalletMoveEnv(t)
|
||||||
|
a := e.member("Anita", "081200005678")
|
||||||
|
res := e.credit(t, earn(a, 10, nil))
|
||||||
|
|
||||||
|
_, err := NewWalletTraceProcessor(walletTraceRepoFake{e}).Trace(e.ctx, uuid.New(), res.Transaction.ID)
|
||||||
|
assert.ErrorIs(t, err, repository.ErrWalletTransactionNotFound)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrWalletTransactionNotFound means the ledger row does not exist or belongs to
|
||||||
|
// another organization.
|
||||||
|
var ErrWalletTransactionNotFound = errors.New("wallet: transaction not found")
|
||||||
|
|
||||||
|
// WalletTraceRepository reads what tracing a ledger row lot by lot needs
|
||||||
|
// (docs/prd-point-coin.md F7, §8.1). Nothing here changes a balance.
|
||||||
|
type WalletTraceRepository interface {
|
||||||
|
// GetTransaction returns ErrWalletTransactionNotFound unless the row belongs to
|
||||||
|
// the organization.
|
||||||
|
GetTransaction(ctx context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error)
|
||||||
|
GetTransactions(ctx context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error)
|
||||||
|
// ListAllocations returns what a debit took from each lot, in the order it took.
|
||||||
|
ListAllocations(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error)
|
||||||
|
// ListLotsBySource returns the lots a credit created, oldest first.
|
||||||
|
ListLotsBySource(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error)
|
||||||
|
GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error)
|
||||||
|
CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type walletTraceRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
names *walletAdminRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWalletTraceRepository(db *gorm.DB) WalletTraceRepository {
|
||||||
|
return &walletTraceRepository{db: db, names: &walletAdminRepository{db: db}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *walletTraceRepository) GetTransaction(ctx context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error) {
|
||||||
|
var rows []entities.WalletTransaction
|
||||||
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||||
|
Where("id = ? AND organization_id = ?", id, organizationID).
|
||||||
|
Limit(1).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get wallet transaction: %w", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, ErrWalletTransactionNotFound
|
||||||
|
}
|
||||||
|
return &rows[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *walletTraceRepository) GetTransactions(ctx context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error) {
|
||||||
|
var rows []entities.WalletTransaction
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id IN ?", ids).Find(&rows).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get wallet transactions: %w", err)
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *walletTraceRepository) ListAllocations(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error) {
|
||||||
|
var rows []entities.WalletLotAllocation
|
||||||
|
// A debit draws in K9 order, so the lots' order is the order it took from them.
|
||||||
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||||
|
Table("wallet_lot_allocations a").
|
||||||
|
Select("a.*").
|
||||||
|
Joins("JOIN wallet_lots l ON l.id = a.lot_id").
|
||||||
|
Where("a.transaction_id = ?", transactionID).
|
||||||
|
Order("l.expires_at NULLS LAST, l.created_at, l.id").
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list wallet lot allocations: %w", err)
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *walletTraceRepository) ListLotsBySource(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) {
|
||||||
|
var lots []entities.WalletLot
|
||||||
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||||
|
Where("source_transaction_id = ?", transactionID).
|
||||||
|
Order("created_at, id").
|
||||||
|
Find(&lots).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list wallet lots: %w", err)
|
||||||
|
}
|
||||||
|
return lots, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *walletTraceRepository) GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
|
||||||
|
var lots []entities.WalletLot
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return lots, nil
|
||||||
|
}
|
||||||
|
if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id IN ?", ids).Find(&lots).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get wallet lots: %w", err)
|
||||||
|
}
|
||||||
|
return lots, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *walletTraceRepository) CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||||
|
return r.names.CustomerNames(ctx, ids)
|
||||||
|
}
|
||||||
@@ -653,6 +653,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
marketingCustomers.DELETE("/:id/pin", r.authMiddleware.RequireLoyaltyManager(), r.customerPinHandler.RemovePin)
|
marketingCustomers.DELETE("/:id/pin", r.authMiddleware.RequireLoyaltyManager(), r.customerPinHandler.RemovePin)
|
||||||
marketingCustomers.GET("/:id/security-events", r.customerPinHandler.ListSecurityEvents)
|
marketingCustomers.GET("/:id/security-events", r.customerPinHandler.ListSecurityEvents)
|
||||||
}
|
}
|
||||||
|
// Trace one ledger row lot by lot back to where its balance came from (F7, §8.1)
|
||||||
|
gamification.GET("/wallet-transactions/:id/trace", r.walletAdminHandler.TraceTransaction)
|
||||||
|
|
||||||
campaignRules := gamification.Group("/campaign-rules")
|
campaignRules := gamification.Group("/campaign-rules")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func TestAllRoutesRegister(t *testing.T) {
|
|||||||
"GET /api/v1/customer/wallet/transactions",
|
"GET /api/v1/customer/wallet/transactions",
|
||||||
"GET /api/v1/marketing/customers/:id/wallet",
|
"GET /api/v1/marketing/customers/:id/wallet",
|
||||||
"POST /api/v1/marketing/customers/:id/wallet/adjust",
|
"POST /api/v1/marketing/customers/:id/wallet/adjust",
|
||||||
|
"GET /api/v1/marketing/wallet-transactions/:id/trace",
|
||||||
"GET /api/v1/outlets/:outlet_id/loyalty-settings",
|
"GET /api/v1/outlets/:outlet_id/loyalty-settings",
|
||||||
"PUT /api/v1/outlets/:outlet_id/loyalty-settings",
|
"PUT /api/v1/outlets/:outlet_id/loyalty-settings",
|
||||||
"GET /api/v1/marketing/loyalty-settings",
|
"GET /api/v1/marketing/loyalty-settings",
|
||||||
|
|||||||
@@ -19,14 +19,25 @@ import (
|
|||||||
type WalletAdminService interface {
|
type WalletAdminService interface {
|
||||||
GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response
|
GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response
|
||||||
AdjustCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.AdjustCustomerWalletRequest) *contract.Response
|
AdjustCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.AdjustCustomerWalletRequest) *contract.Response
|
||||||
|
// TraceTransaction follows a ledger row lot by lot back to where its balance came from.
|
||||||
|
TraceTransaction(ctx context.Context, apctx *appcontext.ContextInfo, transactionID uuid.UUID) *contract.Response
|
||||||
}
|
}
|
||||||
|
|
||||||
type WalletAdminServiceImpl struct {
|
type WalletAdminServiceImpl struct {
|
||||||
walletAdminProcessor *processor.WalletAdminProcessor
|
walletAdminProcessor *processor.WalletAdminProcessor
|
||||||
|
walletTraceProcessor *processor.WalletTraceProcessor
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWalletAdminService(walletAdminProcessor *processor.WalletAdminProcessor) *WalletAdminServiceImpl {
|
func NewWalletAdminService(walletAdminProcessor *processor.WalletAdminProcessor, walletTraceProcessor *processor.WalletTraceProcessor) *WalletAdminServiceImpl {
|
||||||
return &WalletAdminServiceImpl{walletAdminProcessor: walletAdminProcessor}
|
return &WalletAdminServiceImpl{walletAdminProcessor: walletAdminProcessor, walletTraceProcessor: walletTraceProcessor}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WalletAdminServiceImpl) TraceTransaction(ctx context.Context, apctx *appcontext.ContextInfo, transactionID uuid.UUID) *contract.Response {
|
||||||
|
trace, err := s.walletTraceProcessor.Trace(ctx, apctx.OrganizationID, transactionID)
|
||||||
|
if err != nil {
|
||||||
|
return walletErrorResponse(err)
|
||||||
|
}
|
||||||
|
return contract.BuildSuccessResponse(trace)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WalletAdminServiceImpl) GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response {
|
func (s *WalletAdminServiceImpl) GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response {
|
||||||
@@ -56,6 +67,8 @@ func walletErrorResponse(err error) *contract.Response {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, repository.ErrWalletNotFound):
|
case errors.Is(err, repository.ErrWalletNotFound):
|
||||||
code, message = constants.NotFoundErrorCode, "customer not found"
|
code, message = constants.NotFoundErrorCode, "customer not found"
|
||||||
|
case errors.Is(err, repository.ErrWalletTransactionNotFound):
|
||||||
|
code, message = constants.NotFoundErrorCode, "wallet transaction not found"
|
||||||
case errors.Is(err, repository.ErrWalletInsufficientBalance):
|
case errors.Is(err, repository.ErrWalletInsufficientBalance):
|
||||||
code, message = constants.ValidationErrorCode, "the adjustment would take more than the customer can spend"
|
code, message = constants.ValidationErrorCode, "the adjustment would take more than the customer can spend"
|
||||||
case errors.Is(err, processor.ErrInvalidWalletQuery),
|
case errors.Is(err, processor.ErrInvalidWalletQuery),
|
||||||
|
|||||||
Reference in New Issue
Block a user