feat(loyalty): EnakPoint & EnakCoin #32
+3
-1
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user