feat(loyalty): send EnakPoint and EnakCoin to another customer

Adds GET /customer/wallet/transfer/recipient?phone= and
POST /customer/wallet/transfer (docs/prd-point-coin.md F5, Q4, Q16,
PC-402).

The recipient is found by phone number and must be an active customer of
the same organization, not the walk-in customer and not the sender. A
number of another organization answers 404 like an unknown one, so the
check does not reveal who uses the app elsewhere. The recipient check
returns the name and number masked ("Bu*** Sa***", "08**-****-1234").

The organization's transfer settings apply: transfers turned off, the
minimum, the maximum per transaction and the daily limit per currency,
which starts over at midnight WIB. Everything the request alone can get
wrong is refused before the PIN, so it costs no attempt; the PIN then
refuses a transfer held for 24 hours after a PIN reset.

Both wallets are locked in customer_id order, so transfers in opposite
directions cannot deadlock, and the daily limit is summed under the lock.
TRANSFER_OUT takes from the sender's lots in K9 order and TRANSFER_IN
gives the recipient lots with exactly the same expiries, pointing back at
the sender's lots. The rows share a group, reference each other and name
the other customer; descriptions carry only the masked name.

The Idempotency-Key header is required. A retry is recognised under the
lock before the daily limit, so it replays instead of counting twice; the
same key towards another recipient is refused.

The recipient is told by WhatsApp after the commit, as PIN locks are:
NotificationService only reaches staff devices, there is no push channel
to customers yet. A failure to send is logged, never undoes the transfer.

Transfers must not be released before note N3 (legal) is closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 12:14:32 +07:00
co-authored by Claude Opus 5.5
parent ab3425070b
commit 694d65b6d8
12 changed files with 736 additions and 7 deletions
@@ -36,6 +36,9 @@ func newWalletMoveEnv(t *testing.T) *walletMoveEnv {
},
pins: &movePinFake{good: "482913"},
}
e.customers.ledger = e.repo
// Ledger rows are stamped from now on, so "today" is the day of e.now.
e.repo.clock = e.now
return e
}
@@ -54,6 +57,12 @@ func (e *walletMoveEnv) exchanges() *WalletExchangeProcessor {
return p
}
func (e *walletMoveEnv) transfers(messenger walletMessenger) *WalletTransferProcessor {
p := NewWalletTransferProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}, messenger)
p.now = func() time.Time { return e.now }
return p
}
func (e *walletMoveEnv) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
s := *e.settings
return &s, nil
@@ -86,7 +95,8 @@ func (e *walletMoveEnv) coinBalance(t *testing.T, customerID uuid.UUID) int64 {
}
type walletMoveRepoFake struct {
byID map[uuid.UUID]*repository.WalletMoveCustomer
byID map[uuid.UUID]*repository.WalletMoveCustomer
ledger *walletRepoFake
}
func (f *walletMoveRepoFake) GetCustomer(_ context.Context, id uuid.UUID) (*repository.WalletMoveCustomer, error) {
@@ -98,6 +108,25 @@ func (f *walletMoveRepoFake) GetCustomer(_ context.Context, id uuid.UUID) (*repo
return &copied, nil
}
func (f *walletMoveRepoFake) FindCustomerByPhone(ctx context.Context, phone string) (*repository.WalletMoveCustomer, error) {
for id, c := range f.byID {
if c.PhoneNumber != nil && *c.PhoneNumber == phone {
return f.GetCustomer(ctx, id)
}
}
return nil, repository.ErrWalletNotFound
}
func (f *walletMoveRepoFake) TransferredOutSince(_ context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) {
var total int64
for _, tx := range f.ledger.transactions {
if tx.CustomerID == customerID && tx.Currency == currency && tx.Type == constants.WalletTxTypeTransferOut && !tx.CreatedAt.Before(since) {
total -= tx.Amount
}
}
return total, nil
}
// movePinFake accepts one PIN and records the actions it was asked to approve.
type movePinFake struct {
good string
+64 -1
View File
@@ -2,7 +2,9 @@ package processor
import (
"context"
"fmt"
"os"
"sync"
"testing"
"time"
@@ -19,7 +21,9 @@ import (
)
// fixedOrganizationSettings serves the same organization settings to every caller.
type fixedOrganizationSettings struct{ s models.OrganizationLoyaltySettings }
type fixedOrganizationSettings struct {
s models.OrganizationLoyaltySettings
}
func (f fixedOrganizationSettings) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
s := f.s
@@ -92,3 +96,62 @@ func TestWalletExchange_AgainstPostgres(t *testing.T) {
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE group_id = ?`, res.GroupID).Scan(&rows).Error)
assert.Equal(t, int64(2), rows)
}
// Transfers in both directions at once must not deadlock: both lock the two wallets
// in customer_id order. Every one of them lands, and the totals still reconcile.
func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) {
db, _, a, b := walletMoveDB(t)
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
moves := repository.NewWalletMoveRepository(db)
settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
}}
p := NewWalletTransferProcessor(moves, settings, repository.NewWalletQueryRepository(db), &movePinFake{good: "482913"}, wallet, txm, nil)
expiry := time.Now().Add(24 * time.Hour).Truncate(time.Second)
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
if _, err := wallet.Credit(ctx, earn(a, 100, &expiry)); err != nil {
return err
}
_, err := wallet.Credit(ctx, earn(b, 100, nil))
return err
}))
phone := func(id uuid.UUID) string { return "08" + id.String()[:10] }
const rounds = 10
errs := make(chan error, 2*rounds)
var wg sync.WaitGroup
for i := 0; i < rounds; i++ {
for _, pair := range [][2]uuid.UUID{{a, b}, {b, a}} {
wg.Add(1)
go func(from, to uuid.UUID, i int) {
defer wg.Done()
_, err := p.Transfer(context.Background(), from, sendPoints(1, phone(to)), "482913", fmt.Sprintf("race-%d", i), models.CustomerPinRequestInfo{})
errs <- err
}(pair[0], pair[1], i)
}
}
wg.Wait()
close(errs)
for err := range errs {
assert.NoError(t, err)
}
var balances []int64
require.NoError(t, db.Raw(`SELECT point_balance FROM customer_wallets WHERE customer_id IN ? ORDER BY point_balance`, []uuid.UUID{a, b}).Scan(&balances).Error)
assert.Equal(t, []int64{100, 100}, balances)
// B's lots that came from A keep A's expiry to the second.
var mismatched int64
require.NoError(t, db.Raw(`
SELECT COUNT(*) FROM wallet_lots l JOIN wallet_lots o ON o.id = l.origin_lot_id
WHERE l.customer_id = ? AND o.customer_id = ? AND l.expires_at IS DISTINCT FROM o.expires_at`, b, a).Scan(&mismatched).Error)
assert.Zero(t, mismatched)
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
sent, err := moves.TransferredOutSince(ctx, a, constants.WalletCurrencyPoint, startOfWalletDay(time.Now()))
assert.Equal(t, int64(rounds), sent)
return err
}))
}
@@ -0,0 +1,292 @@
package processor
import (
"context"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// ErrWalletRecipientNotFound means no customer of the sender's organization has the
// phone number. A customer of another organization is reported the same way, so the
// check does not reveal who uses the app elsewhere.
var ErrWalletRecipientNotFound = errors.New("no customer of this organization has that phone number")
// walletMessenger tells a customer something happened to their wallet. There is no
// push channel to customers yet, so the app sends it by WhatsApp.
type walletMessenger interface {
SendWhatsAppMessage(phoneNumber, message string) error
}
// WalletTransferProcessor sends EnakPoint or EnakCoin from one customer to another in
// the same organization (docs/prd-point-coin.md F5).
type WalletTransferProcessor struct {
customers repository.WalletMoveRepository
settings organizationSettingsReader
spendable spendableReader
pins pinVerifier
wallet *WalletProcessor
tx TxRunner
messenger walletMessenger
now func() time.Time
}
func NewWalletTransferProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner, messenger walletMessenger) *WalletTransferProcessor {
return &WalletTransferProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, messenger: messenger, now: time.Now}
}
// Recipient is GET /customer/wallet/transfer/recipient: the masked name and number
// of the customer a phone number belongs to, if the sender may send to them.
func (p *WalletTransferProcessor) Recipient(ctx context.Context, senderID uuid.UUID, phoneNumber string) (*models.WalletTransferRecipient, error) {
sender, err := p.customers.GetCustomer(ctx, senderID)
if err != nil {
return nil, err
}
recipient, err := p.recipient(ctx, sender, phoneNumber)
if err != nil {
return nil, err
}
return maskedRecipient(recipient), nil
}
// Transfer sends in.Amount of in.Currency to the customer with in.RecipientPhone,
// approved by the sender's PIN (K8), and tells the recipient.
//
// Both wallets are locked in customer_id order, so two transfers in opposite
// directions cannot deadlock. TRANSFER_OUT takes from the sender's lots in K9 order,
// and TRANSFER_IN gives the recipient lots with exactly the same expiries, pointing
// back at the sender's lots, so sending a balance back and forth cannot extend it.
// The two rows share a group and name each other's customer.
//
// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns
// the first transfer without moving anything again or counting against the limits.
func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UUID, in models.WalletTransfer, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.WalletTransferResult, error) {
reject := func(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrWalletMoveRejected, fmt.Sprintf(format, args...))
}
key, err := walletMoveKey(idempotencyKey)
if err != nil {
return nil, err
}
currency := strings.ToUpper(strings.TrimSpace(in.Currency))
if !constants.IsValidWalletCurrency(currency) {
return nil, reject("currency must be POINT or COIN")
}
if in.Amount <= 0 {
return nil, reject("the amount must be positive")
}
sender, err := p.customers.GetCustomer(ctx, senderID)
if err != nil {
return nil, err
}
if !sender.IsActive {
return nil, reject("the customer is not active")
}
settings, err := p.settings.Organization(ctx, sender.OrganizationID)
if err != nil {
return nil, err
}
limits := settings.Transfer
switch {
case !limits.Enabled:
return nil, reject("transfers are turned off")
case in.Amount < limits.MinAmount:
return nil, reject("at least %d can be sent at a time", limits.MinAmount)
case limits.MaxPerTransaction != nil && in.Amount > *limits.MaxPerTransaction:
return nil, reject("at most %d can be sent at a time", *limits.MaxPerTransaction)
}
recipient, err := p.recipient(ctx, sender, in.RecipientPhone)
if err != nil {
return nil, err
}
// Everything the request alone can get wrong is refused above, before the PIN, so
// it costs no attempt. The PIN also refuses a transfer held after a PIN reset.
if err := p.pins.VerifyPin(ctx, senderID, pin, PinActionTransfer, info); err != nil {
return nil, err
}
to, from := maskedRecipient(recipient), maskedRecipient(sender)
outKey := fmt.Sprintf("transfer:%s:%s:out", senderID, key)
inKey := fmt.Sprintf("transfer:%s:%s:in", senderID, key)
result := &models.WalletTransferResult{Currency: currency, Amount: in.Amount, Recipient: *to}
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if err := p.wallet.LockWallets(ctx, senderID, recipient.ID); err != nil {
return err
}
groupID, outID, inID := uuid.New(), uuid.New(), uuid.New()
previous, err := p.wallet.FindTransaction(ctx, outKey)
if err != nil {
return err
}
if previous != nil {
// A retry: it replays below, so it must not count against the daily limit
// it is already part of.
if previous.CounterpartyCustomerID == nil || *previous.CounterpartyCustomerID != recipient.ID || previous.GroupID == nil {
return ErrWalletIdempotencyConflict
}
outID, inID, groupID = previous.ID, previous.ReferenceID, *previous.GroupID
} else if limits.DailyLimit != nil {
sent, err := p.customers.TransferredOutSince(ctx, senderID, currency, startOfWalletDay(p.now()))
if err != nil {
return err
}
if sent+in.Amount > *limits.DailyLimit {
return reject("at most %d can be sent per day; %d is left today", *limits.DailyLimit, max(*limits.DailyLimit-sent, 0))
}
}
out, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
TransactionID: outID,
CustomerID: senderID,
Currency: currency,
Type: constants.WalletTxTypeTransferOut,
Amount: in.Amount,
ReferenceType: constants.WalletRefTypeWalletTx,
ReferenceID: inID,
GroupID: &groupID,
CounterpartyCustomerID: &recipient.ID,
Description: truncateRunes(fmt.Sprintf("Transfer ke %s (%s)", to.Name, to.PhoneNumber), walletDescriptionLimit),
IdempotencyKey: outKey,
}})
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
return reject("not enough %s", walletCurrencyName(currency))
}
if err != nil {
return err
}
received, err := p.wallet.Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{
TransactionID: inID,
CustomerID: recipient.ID,
Currency: currency,
Type: constants.WalletTxTypeTransferIn,
Amount: in.Amount,
ReferenceType: constants.WalletRefTypeWalletTx,
ReferenceID: outID,
GroupID: &groupID,
CounterpartyCustomerID: &senderID,
Description: truncateRunes(fmt.Sprintf("Transfer dari %s (%s)", from.Name, from.PhoneNumber), walletDescriptionLimit),
IdempotencyKey: inKey,
},
Lots: out.CarryOver(),
})
if err != nil {
return err
}
result.GroupID = groupID
result.Lots = movedLots(received.Lots)
result.Replayed = out.Replayed
return nil
})
if err != nil {
return nil, err
}
if !result.Replayed {
p.tellRecipient(recipient, from, currency, in.Amount)
}
balances, err := p.spendable.SpendableBalances(ctx, senderID, p.now())
if err != nil {
return nil, err
}
result.Balance = balances[currency]
return result, nil
}
// recipient finds who a phone number belongs to and checks the sender may send to
// them: an active customer of the same organization, not the walk-in customer, and
// not the sender.
func (p *WalletTransferProcessor) recipient(ctx context.Context, sender *repository.WalletMoveCustomer, phoneNumber string) (*repository.WalletMoveCustomer, error) {
phoneNumber = strings.TrimSpace(phoneNumber)
if phoneNumber == "" {
return nil, fmt.Errorf("%w: the recipient's phone number is required", ErrWalletMoveRejected)
}
recipient, err := p.customers.FindCustomerByPhone(ctx, phoneNumber)
if errors.Is(err, repository.ErrWalletNotFound) {
return nil, ErrWalletRecipientNotFound
}
if err != nil {
return nil, err
}
switch {
case recipient.OrganizationID != sender.OrganizationID:
return nil, ErrWalletRecipientNotFound
case recipient.ID == sender.ID:
return nil, fmt.Errorf("%w: you cannot send to yourself", ErrWalletMoveRejected)
case recipient.IsDefault || !recipient.IsActive:
return nil, fmt.Errorf("%w: this customer cannot receive transfers", ErrWalletMoveRejected)
}
return recipient, nil
}
// tellRecipient is best effort: the transfer has already happened, so a failure to
// send the message is only logged.
func (p *WalletTransferProcessor) tellRecipient(recipient *repository.WalletMoveCustomer, sender *models.WalletTransferRecipient, currency string, amount int64) {
if p.messenger == nil || recipient.PhoneNumber == nil {
return
}
message := fmt.Sprintf("Kamu menerima %d %s dari %s (%s). Cek riwayatnya di aplikasi.",
amount, walletCurrencyName(currency), sender.Name, sender.PhoneNumber)
if err := p.messenger.SendWhatsAppMessage(*recipient.PhoneNumber, message); err != nil {
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about a transfer", recipient.ID), err)
}
}
func maskedRecipient(c *repository.WalletMoveCustomer) *models.WalletTransferRecipient {
phone := ""
if c.PhoneNumber != nil {
phone = maskPhoneNumber(*c.PhoneNumber)
}
return &models.WalletTransferRecipient{Name: maskName(c.Name), PhoneNumber: phone}
}
// maskName keeps the first two letters of each word, "Budi Santoso" → "Bu*** Sa***",
// and one letter of a word that short, so the sender can recognise the recipient
// without the app revealing their name (F5, §8.1).
func maskName(name string) string {
words := strings.Fields(name)
if len(words) == 0 {
return "***"
}
for i, w := range words {
keep := 2
if utf8.RuneCountInString(w) <= 2 {
keep = 1
}
words[i] = string([]rune(w)[:keep]) + "***"
}
return strings.Join(words, " ")
}
// maskPhoneNumber keeps the first two and the last four digits:
// "081234561234" → "08**-****-1234".
func maskPhoneNumber(phone string) string {
runes := []rune(strings.TrimSpace(phone))
if len(runes) < 8 {
return "****"
}
return string(runes[:2]) + "**-****-" + string(runes[len(runes)-4:])
}
func walletCurrencyName(currency string) string {
if currency == constants.WalletCurrencyCoin {
return "EnakCoin"
}
return "EnakPoint"
}
// startOfWalletDay is midnight of t's day in the customer's time zone, where the
// daily transfer limit starts over.
func startOfWalletDay(t time.Time) time.Time {
local := t.In(walletDisplayLocation)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, walletDisplayLocation)
}
@@ -0,0 +1,225 @@
package processor
import (
"errors"
"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/models"
"apskel-pos-be/internal/repository"
)
type messengerFake struct{ sent map[string][]string }
func (f *messengerFake) SendWhatsAppMessage(phone, message string) error {
if f.sent == nil {
f.sent = map[string][]string{}
}
f.sent[phone] = append(f.sent[phone], message)
return nil
}
func sendPoints(amount int64, phone string) models.WalletTransfer {
return models.WalletTransfer{Currency: constants.WalletCurrencyPoint, Amount: amount, RecipientPhone: phone}
}
func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi Santoso", "081234561234")
dec, jan := e.at(30*24*time.Hour), e.at(60*24*time.Hour)
first := e.credit(t, earn(a, 100, dec))
second := e.credit(t, earn(a, 50, jan))
messenger := &messengerFake{}
// The example in §8: A sends 120, 100 from the lot expiring first and 20 from the next.
res, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, int64(30), res.Balance)
assert.Equal(t, models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, res.Recipient)
assert.Equal(t, []models.WalletMovedLot{{Amount: 100, ExpiresAt: dec}, {Amount: 20, ExpiresAt: jan}}, res.Lots)
assert.Equal(t, int64(120), e.balance(t, b))
assert.Equal(t, []PinAction{PinActionTransfer}, e.pins.actions)
var bLots []uuid.UUID
for _, lot := range e.repo.lots {
if lot.CustomerID == b {
require.NotNil(t, lot.OriginLotID)
bLots = append(bLots, *lot.OriginLotID)
for _, origin := range e.repo.lots {
if origin.ID == *lot.OriginLotID {
assert.Equal(t, origin.ExpiresAt, lot.ExpiresAt, "the recipient's lot expires exactly when the sender's did")
}
}
}
}
assert.Equal(t, []uuid.UUID{first.Lots[0].ID, second.Lots[0].ID}, bLots)
out, in := e.repo.transactions[2], e.repo.transactions[3]
assert.Equal(t, constants.WalletTxTypeTransferOut, out.Type)
assert.Equal(t, b, *out.CounterpartyCustomerID)
assert.Equal(t, in.ID, out.ReferenceID)
assert.Equal(t, "Transfer ke Bu*** Sa*** (08**-****-1234)", out.Description)
assert.Equal(t, constants.WalletTxTypeTransferIn, in.Type)
assert.Equal(t, a, *in.CounterpartyCustomerID)
assert.Equal(t, out.ID, in.ReferenceID)
assert.Equal(t, *out.GroupID, *in.GroupID)
assert.Equal(t, "Transfer dari An*** (08**-****-5678)", in.Description)
assert.GreaterOrEqual(t, e.repo.locks[a], 1)
assert.GreaterOrEqual(t, e.repo.locks[b], 1)
assert.Equal(t, []string{"Kamu menerima 120 EnakPoint dari An*** (08**-****-5678). Cek riwayatnya di aplikasi."}, messenger.sent["081234561234"])
}
func TestWalletTransfer_Coins(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi", "081234561234")
e.earnCoins(t, a, 10, nil)
_, err := e.transfers(nil).Transfer(e.ctx, a, models.WalletTransfer{Currency: "coin", Amount: 4, RecipientPhone: "081234561234"}, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, int64(6), e.coinBalance(t, a))
assert.Equal(t, int64(4), e.coinBalance(t, b))
}
func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.credit(t, earn(a, 100, nil))
walkIn := e.member("Walk-in", "081100000000")
e.customers.byID[walkIn].IsDefault = true
inactive := e.member("Old", "081100000001")
e.customers.byID[inactive].IsActive = false
elsewhere := e.member("Other Org", "081100000002")
e.customers.byID[elsewhere].OrganizationID = uuid.New()
for phone, want := range map[string]error{
"081200005678": ErrWalletMoveRejected, // herself
"081100000000": ErrWalletMoveRejected, // the walk-in customer
"081100000001": ErrWalletMoveRejected, // inactive
"081100000002": ErrWalletRecipientNotFound, // another organization looks like nobody
"081999999999": ErrWalletRecipientNotFound,
"": ErrWalletMoveRejected,
} {
_, err := e.transfers(nil).Recipient(e.ctx, a, phone)
assert.ErrorIs(t, err, want, phone)
_, err = e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, phone), "482913", "key-"+phone, models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, want, phone)
}
assert.Empty(t, e.pins.actions, "refused before the PIN")
assert.Equal(t, int64(100), e.balance(t, a))
}
func TestWalletTransfer_RecipientIsMasked(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.member("Budi Santoso", "081234561234")
got, err := e.transfers(nil).Recipient(e.ctx, a, " 081234561234 ")
require.NoError(t, err)
assert.Equal(t, &models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, got)
}
func TestWalletTransfer_OrganizationLimits(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.member("Budi", "081234561234")
e.credit(t, earn(a, 1000, nil))
e.settings.Transfer = models.LoyaltyTransferSettings{Enabled: true, MinAmount: 10, MaxPerTransaction: ptr(int64(300)), DailyLimit: ptr(int64(500))}
send := func(amount int64, key string) error {
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(amount, "081234561234"), "482913", key, models.CustomerPinRequestInfo{})
return err
}
assert.ErrorIs(t, send(9, "below-min"), ErrWalletMoveRejected)
assert.ErrorIs(t, send(301, "above-max"), ErrWalletMoveRejected)
require.NoError(t, send(300, "k1"))
require.NoError(t, send(200, "k2"))
// The daily limit is used up; a retry of a transfer already made still replays.
assert.ErrorIs(t, send(10, "k3"), ErrWalletMoveRejected)
require.NoError(t, send(200, "k2"))
// It starts over the next day in the customer's time zone.
e.now = startOfWalletDay(e.now).AddDate(0, 0, 1).Add(time.Minute)
e.repo.clock = e.now
require.NoError(t, send(10, "k4"))
assert.Equal(t, int64(490), e.balance(t, a))
e.settings.Transfer.Enabled = false
assert.ErrorIs(t, send(10, "k5"), ErrWalletMoveRejected)
}
func TestWalletTransfer_HeldAfterPinReset(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.member("Budi", "081234561234")
e.credit(t, earn(a, 100, nil))
until := e.now.Add(time.Hour)
e.pins.err = &PinError{Code: PinErrTransferBlocked, Until: &until}
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
var pinErr *PinError
require.True(t, errors.As(err, &pinErr))
assert.Equal(t, PinErrTransferBlocked, pinErr.Code)
assert.Equal(t, int64(100), e.balance(t, a))
}
func TestWalletTransfer_NotEnoughBalance(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi", "081234561234")
e.credit(t, earn(a, 100, nil))
// An expired lot cannot be sent even before the expiry job takes it.
e.credit(t, earn(a, 50, e.at(-time.Hour)))
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
assert.Equal(t, int64(0), e.balance(t, b))
}
func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi", "081234561234")
e.member("Citra", "081255550000")
e.credit(t, earn(a, 100, nil))
messenger := &messengerFake{}
first, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
again, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.True(t, again.Replayed)
assert.Equal(t, first.GroupID, again.GroupID)
assert.Equal(t, int64(40), e.balance(t, b))
assert.Len(t, messenger.sent["081234561234"], 1)
// The same key to someone else is not a retry.
_, err = e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081255550000"), "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
}
func TestWalletTransfer_UnknownSender(t *testing.T) {
e := newWalletMoveEnv(t)
_, err := e.transfers(nil).Transfer(e.ctx, uuid.New(), sendPoints(1, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, repository.ErrWalletNotFound)
}
func TestMaskName(t *testing.T) {
assert.Equal(t, "Bu*** Sa***", maskName("Budi Santoso"))
assert.Equal(t, "An***", maskName("Anita"))
assert.Equal(t, "A*** Ra***", maskName("Al Rahman"))
assert.Equal(t, "***", maskName(" "))
assert.Equal(t, "08**-****-1234", maskPhoneNumber("081234561234"))
assert.Equal(t, "+6**-****-1234", maskPhoneNumber("+6281234561234"))
assert.Equal(t, "****", maskPhoneNumber("12345"))
}