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>
247 lines
9.7 KiB
Go
247 lines
9.7 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"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 pushFake struct {
|
|
title, body string
|
|
data map[string]string
|
|
}
|
|
|
|
// notifierFake records the pushes each customer would get.
|
|
type notifierFake struct{ pushes map[uuid.UUID][]pushFake }
|
|
|
|
func (f *notifierFake) Notify(_ context.Context, customerID uuid.UUID, title, body string, data map[string]string) error {
|
|
if f.pushes == nil {
|
|
f.pushes = map[uuid.UUID][]pushFake{}
|
|
}
|
|
f.pushes[customerID] = append(f.pushes[customerID], pushFake{title: title, body: body, data: data})
|
|
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", "6281200005678")
|
|
b := e.member("Budi Santoso", "6281234561234")
|
|
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))
|
|
notifier := ¬ifierFake{}
|
|
|
|
// The example in §8: A sends 120, 100 from the lot expiring first and 20 from the next.
|
|
res, err := e.transfers(notifier).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, []pushFake{{
|
|
title: "EnakPoint masuk",
|
|
body: "Kamu menerima 120 EnakPoint dari An*** (08**-****-5678).",
|
|
data: map[string]string{
|
|
"type": NotificationTypeWalletTransferIn,
|
|
"transaction_id": in.ID.String(),
|
|
"group_id": in.GroupID.String(),
|
|
"currency": constants.WalletCurrencyPoint,
|
|
"amount": "120",
|
|
},
|
|
}}, notifier.pushes[b])
|
|
assert.Empty(t, notifier.pushes[a], "the sender gets no push")
|
|
}
|
|
|
|
func TestWalletTransfer_Coins(t *testing.T) {
|
|
e := newWalletMoveEnv(t)
|
|
a := e.member("Anita", "6281200005678")
|
|
b := e.member("Budi", "6281234561234")
|
|
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", "6281200005678")
|
|
e.credit(t, earn(a, 100, nil))
|
|
|
|
walkIn := e.member("Walk-in", "6281100000000")
|
|
e.customers.byID[walkIn].IsDefault = true
|
|
inactive := e.member("Old", "6281100000001")
|
|
e.customers.byID[inactive].IsActive = false
|
|
elsewhere := e.member("Other Org", "6281100000002")
|
|
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,
|
|
"021-1234567": ErrWalletMoveRejected, // not a mobile number
|
|
} {
|
|
_, 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", "6281200005678")
|
|
e.member("Budi Santoso", "6281234561234")
|
|
|
|
for _, phone := range []string{" 081234561234 ", "6281234561234", "+62 812-3456-1234"} {
|
|
got, err := e.transfers(nil).Recipient(e.ctx, a, phone)
|
|
require.NoError(t, err, phone)
|
|
assert.Equal(t, &models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, got, phone)
|
|
}
|
|
}
|
|
|
|
func TestWalletTransfer_OrganizationLimits(t *testing.T) {
|
|
e := newWalletMoveEnv(t)
|
|
a := e.member("Anita", "6281200005678")
|
|
e.member("Budi", "6281234561234")
|
|
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", "6281200005678")
|
|
e.member("Budi", "6281234561234")
|
|
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", "6281200005678")
|
|
b := e.member("Budi", "6281234561234")
|
|
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", "6281200005678")
|
|
b := e.member("Budi", "6281234561234")
|
|
e.member("Citra", "6281255550000")
|
|
e.credit(t, earn(a, 100, nil))
|
|
notifier := ¬ifierFake{}
|
|
|
|
first, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
|
require.NoError(t, err)
|
|
again, err := e.transfers(notifier).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, notifier.pushes[b], 1)
|
|
|
|
// The same key to someone else is not a retry.
|
|
_, err = e.transfers(notifier).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"))
|
|
}
|