feat(loyalty): EnakPoint & EnakCoin #32

Merged
aefril merged 36 commits from feature/point-coint into main 2026-09-30 10:03:08 +02:00
12 changed files with 574 additions and 15 deletions
Showing only changes of commit 8bf1d5c1a8 - Show all commits
+3 -1
View File
@@ -395,6 +395,7 @@ type processors struct {
pointPaymentProcessor *processor.PointPaymentProcessor
walletExchangeProcessor *processor.WalletExchangeProcessor
walletTransferProcessor *processor.WalletTransferProcessor
walletTraceProcessor *processor.WalletTraceProcessor
}
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,
walletExchangeProcessor: walletExchangeProcessor,
walletTransferProcessor: walletTransferProcessor,
walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
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),
expenseService: service.NewExpenseService(processors.expenseProcessor),
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor),
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor, processors.walletTraceProcessor),
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo),
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor),
pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor),
+1 -1
View File
@@ -74,7 +74,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
}))
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(),
)
gin.SetMode(gin.TestMode)
+14
View File
@@ -83,3 +83,17 @@ func (h *WalletAdminHandler) AdjustCustomerWallet(c *gin.Context) {
}
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")
}
+46
View File
@@ -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"`
}
+1 -11
View File
@@ -69,17 +69,7 @@ func (p *WalletAdminProcessor) GetCustomerWallet(ctx context.Context, organizati
return nil, err
}
for _, lot := range lots {
result.Lots = append(result.Lots, 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,
})
result.Lots = append(result.Lots, adminWalletLot(lot, now))
}
rows, total, err := p.query.ListTransactions(ctx, filter)
+41
View File
@@ -155,3 +155,44 @@ func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) {
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)
}
+2
View File
@@ -653,6 +653,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
marketingCustomers.DELETE("/:id/pin", r.authMiddleware.RequireLoyaltyManager(), r.customerPinHandler.RemovePin)
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")
{
+1
View File
@@ -30,6 +30,7 @@ func TestAllRoutesRegister(t *testing.T) {
"GET /api/v1/customer/wallet/transactions",
"GET /api/v1/marketing/customers/:id/wallet",
"POST /api/v1/marketing/customers/:id/wallet/adjust",
"GET /api/v1/marketing/wallet-transactions/:id/trace",
"GET /api/v1/outlets/:outlet_id/loyalty-settings",
"PUT /api/v1/outlets/:outlet_id/loyalty-settings",
"GET /api/v1/marketing/loyalty-settings",
+15 -2
View File
@@ -19,14 +19,25 @@ import (
type WalletAdminService interface {
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
// 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 {
walletAdminProcessor *processor.WalletAdminProcessor
walletTraceProcessor *processor.WalletTraceProcessor
}
func NewWalletAdminService(walletAdminProcessor *processor.WalletAdminProcessor) *WalletAdminServiceImpl {
return &WalletAdminServiceImpl{walletAdminProcessor: walletAdminProcessor}
func NewWalletAdminService(walletAdminProcessor *processor.WalletAdminProcessor, walletTraceProcessor *processor.WalletTraceProcessor) *WalletAdminServiceImpl {
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 {
@@ -56,6 +67,8 @@ func walletErrorResponse(err error) *contract.Response {
switch {
case errors.Is(err, repository.ErrWalletNotFound):
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):
code, message = constants.ValidationErrorCode, "the adjustment would take more than the customer can spend"
case errors.Is(err, processor.ErrInvalidWalletQuery),