diff --git a/internal/app/app.go b/internal/app/app.go index 0329920..63437f9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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), diff --git a/internal/handler/wallet_admin_db_test.go b/internal/handler/wallet_admin_db_test.go index 1e1a690..5ca8677 100644 --- a/internal/handler/wallet_admin_db_test.go +++ b/internal/handler/wallet_admin_db_test.go @@ -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) diff --git a/internal/handler/wallet_admin_handler.go b/internal/handler/wallet_admin_handler.go index ff2311e..648cbb5 100644 --- a/internal/handler/wallet_admin_handler.go +++ b/internal/handler/wallet_admin_handler.go @@ -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") +} diff --git a/internal/models/wallet_trace.go b/internal/models/wallet_trace.go new file mode 100644 index 0000000..809d7c9 --- /dev/null +++ b/internal/models/wallet_trace.go @@ -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"` +} diff --git a/internal/processor/wallet_admin_processor.go b/internal/processor/wallet_admin_processor.go index 86483f7..d48c495 100644 --- a/internal/processor/wallet_admin_processor.go +++ b/internal/processor/wallet_admin_processor.go @@ -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) diff --git a/internal/processor/wallet_move_db_test.go b/internal/processor/wallet_move_db_test.go index a7d3690..0f654d5 100644 --- a/internal/processor/wallet_move_db_test.go +++ b/internal/processor/wallet_move_db_test.go @@ -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) +} diff --git a/internal/processor/wallet_trace_processor.go b/internal/processor/wallet_trace_processor.go new file mode 100644 index 0000000..dc37e60 --- /dev/null +++ b/internal/processor/wallet_trace_processor.go @@ -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 +} diff --git a/internal/processor/wallet_trace_processor_test.go b/internal/processor/wallet_trace_processor_test.go new file mode 100644 index 0000000..2a0aa01 --- /dev/null +++ b/internal/processor/wallet_trace_processor_test.go @@ -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) +} diff --git a/internal/repository/wallet_trace_repository.go b/internal/repository/wallet_trace_repository.go new file mode 100644 index 0000000..aad7677 --- /dev/null +++ b/internal/repository/wallet_trace_repository.go @@ -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) +} diff --git a/internal/router/router.go b/internal/router/router.go index 1bec687..365c95b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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") { diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 5887bae..94fe041 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -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", diff --git a/internal/service/wallet_admin_service.go b/internal/service/wallet_admin_service.go index 9ce934e..c29580d 100644 --- a/internal/service/wallet_admin_service.go +++ b/internal/service/wallet_admin_service.go @@ -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),