feat(loyalty): EnakPoint & EnakCoin #32
+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