Adds GET /marketing/wallet-transactions/:id/trace (docs/prd-point-coin.md F7, §8.1, PC-404). From any ledger row of the organization, the trace lists the lots a debit took from, with how much it took from each, or the lots a credit created. Each lot is followed back through origin_lot_id, across transfers, exchanges and refunds, to the lot an EARN, ADJUSTMENT or MIGRATION first created. Every step shows the lot and the row that created it, with the real name of the customer it belongs to, so the example of §8 (A sends 120 to B, B pays 30) leads from B's payment to A's order #ORD-1. Lots are loaded a generation at a time, and a chain stops at 100 steps or at a lot it has already seen, which only bad data could cause. A row of another organization answers 404. The dashboard's wallet view now builds its lots with the same helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
5.6 KiB
Go
162 lines
5.6 KiB
Go
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)
|
|
}
|