Files
apskel-pos-backend/internal/processor/wallet_trace_processor_test.go
T
efrilmandClaude Opus 5.5 19afa50b9c feat(customers): store customer phone numbers as 62…
A customer's phone number (customers.phone_number, the one they log in with) has
one stored form, 62 followed by the number without its 0: 6281234561234.
util.NormalizePhoneNumber rewrites 0812…, +62 812…, 62812…, 812… and +62 0812…,
with spaces, dashes, dots or parentheses, to it, and refuses anything that is not
an Indonesian mobile number (628 and 7 to 11 more digits).

It is applied wherever a customer types their number: check-phone, register,
login and resend-OTP (the validator rewrites the request), and the transfer
recipient. Before, the number was matched as typed and a leading 0 was refused,
so the same customer written another way was not found. The masked recipient
stays 08**-****-1234 as customers write numbers.

Migration 000116 rewrites the numbers already stored in customers and
otp_sessions. When several become the same number, the customer that already has
it keeps it, else a registered one, else the oldest; the others, and numbers that
are not Indonesian mobile numbers, are left as they are and cannot log in until
fixed by hand (the query to find them is in the migration). Down does nothing.

The migration was run, twice, against Postgres with a reduced customers and
otp_sessions schema and sample numbers; not against the real schema. The
Postgres tests were not run. customers.phone (the POS contact number) is not
touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:58:24 +07:00

162 lines
5.7 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
// redeems 30. Tracing B's redemption leads to A's order #ORD-1.
func TestWalletTrace_RedemptionLeadsBackToTheSendersOrder(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "6281200005678")
b := e.member("Budi Santoso", "6281234561234")
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, redeem(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.WalletTxTypeRewardRedeem, 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", "6281200005678")
b := e.member("Budi", "6281234561234")
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", "6281200005678")
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)
}