feat(loyalty): trace a wallet row lot by lot in the dashboard

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>
This commit is contained in:
efrilm
2026-09-30 12:25:17 +07:00
co-authored by Claude Opus 5.5
parent a18bb072f5
commit 8bf1d5c1a8
12 changed files with 574 additions and 15 deletions
+41
View File
@@ -155,3 +155,44 @@ func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) {
return err
}))
}
// The example of §8 against Postgres: B's payment of 30 traces back to A's #ORD-1.
func TestWalletTrace_AgainstPostgres(t *testing.T) {
db, org, a, b := walletMoveDB(t)
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
}}
transfers := NewWalletTransferProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db), &movePinFake{good: "482913"}, wallet, txm, nil)
dec, jan := time.Now().Add(30*24*time.Hour), time.Now().Add(60*24*time.Hour)
ord1 := earn(a, 100, &dec)
ord1.Description = "Belanja #ORD-1"
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
if _, err := wallet.Credit(ctx, ord1); err != nil {
return err
}
_, err := wallet.Credit(ctx, earn(a, 50, &jan))
return err
}))
_, err := transfers.Transfer(context.Background(), a, sendPoints(120, "08"+b.String()[:10]), "482913", "trace", models.CustomerPinRequestInfo{})
require.NoError(t, err)
var payment *WalletResult
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
payment, err = wallet.Debit(ctx, pay(b, 30))
return err
}))
trace, err := NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), org, payment.Transaction.ID)
require.NoError(t, err)
require.Len(t, trace.Lots, 1)
chain := trace.Lots[0].Chain
require.Len(t, chain, 2)
assert.Equal(t, constants.WalletTxTypeTransferIn, chain[0].Source.Type)
assert.Equal(t, "Anita", chain[1].Source.Customer.Name)
assert.Equal(t, ord1.ReferenceID, chain[1].Source.ReferenceID)
_, err = NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), uuid.New(), payment.Transaction.ID)
assert.ErrorIs(t, err, repository.ErrWalletTransactionNotFound)
}