Feat/enakgame #46

Merged
aefril merged 4 commits from feat/enakgame into staging 2026-10-09 18:02:40 +02:00
11 changed files with 234 additions and 57 deletions
Showing only changes of commit 19afa50b9c - Show all commits
@@ -152,7 +152,7 @@ func (f *movePinFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, acti
func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) { func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
c := e.member("Budi Santoso", "081234561234") c := e.member("Budi Santoso", "6281234561234")
e.earnCoins(t, c, 50, nil) e.earnCoins(t, c, 50, nil)
res, err := e.exchanges().Exchange(e.ctx, c, 50, "482913", "key-1", models.CustomerPinRequestInfo{}) res, err := e.exchanges().Exchange(e.ctx, c, 50, "482913", "key-1", models.CustomerPinRequestInfo{})
@@ -187,7 +187,7 @@ func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) {
func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) { func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
c := e.member("Budi", "081234561234") c := e.member("Budi", "6281234561234")
e.earnCoins(t, c, 35, nil) e.earnCoins(t, c, 35, nil)
preview, err := e.exchanges().Preview(e.ctx, c, 30) preview, err := e.exchanges().Preview(e.ctx, c, 30)
@@ -206,7 +206,7 @@ func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) {
func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) { func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
c := e.member("Budi", "081234561234") c := e.member("Budi", "6281234561234")
e.earnCoins(t, c, 50, nil) e.earnCoins(t, c, 50, nil)
preview, err := e.exchanges().Preview(e.ctx, c, 25) preview, err := e.exchanges().Preview(e.ctx, c, 25)
@@ -227,7 +227,7 @@ func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) {
func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) { func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
c := e.member("Budi", "081234561234") c := e.member("Budi", "6281234561234")
soon, later := e.at(24*time.Hour), e.at(48*time.Hour) soon, later := e.at(24*time.Hour), e.at(48*time.Hour)
first := e.earnCoins(t, c, 15, soon) first := e.earnCoins(t, c, 15, soon)
second := e.earnCoins(t, c, 15, later) second := e.earnCoins(t, c, 15, later)
@@ -268,7 +268,7 @@ func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) {
func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) { func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1} e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}
c := e.member("Budi", "081234561234") c := e.member("Budi", "6281234561234")
e.earnCoins(t, c, 5, e.at(time.Hour)) e.earnCoins(t, c, 5, e.at(time.Hour))
e.earnCoins(t, c, 5, nil) e.earnCoins(t, c, 5, nil)
@@ -281,7 +281,7 @@ func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) {
func TestWalletExchange_NotEnoughCoins(t *testing.T) { func TestWalletExchange_NotEnoughCoins(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234") c := e.member("Budi", "6281234561234")
e.earnCoins(t, c, 5, nil) e.earnCoins(t, c, 5, nil)
preview, err := e.exchanges().Preview(e.ctx, c, 6) preview, err := e.exchanges().Preview(e.ctx, c, 6)
@@ -295,7 +295,7 @@ func TestWalletExchange_NotEnoughCoins(t *testing.T) {
func TestWalletExchange_WrongPinMovesNothing(t *testing.T) { func TestWalletExchange_WrongPinMovesNothing(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234") c := e.member("Budi", "6281234561234")
e.earnCoins(t, c, 5, nil) e.earnCoins(t, c, 5, nil)
_, err := e.exchanges().Exchange(e.ctx, c, 5, "000000", "key-1", models.CustomerPinRequestInfo{}) _, err := e.exchanges().Exchange(e.ctx, c, 5, "000000", "key-1", models.CustomerPinRequestInfo{})
@@ -307,7 +307,7 @@ func TestWalletExchange_WrongPinMovesNothing(t *testing.T) {
func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) { func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234") c := e.member("Budi", "6281234561234")
e.earnCoins(t, c, 100, nil) e.earnCoins(t, c, 100, nil)
first, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{}) first, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
@@ -330,7 +330,7 @@ func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) {
func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) { func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234") c := e.member("Budi", "6281234561234")
e.earnCoins(t, c, 5, nil) e.earnCoins(t, c, 5, nil)
_, err := e.exchanges().Exchange(e.ctx, c, 5, "482913", " ", models.CustomerPinRequestInfo{}) _, err := e.exchanges().Exchange(e.ctx, c, 5, "482913", " ", models.CustomerPinRequestInfo{})
@@ -344,7 +344,7 @@ func TestWalletExchange_CappedByThePointExpiry(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
e.now = wib(2026, 6, 1, 10, 0) e.now = wib(2026, 6, 1, 10, 0)
e.settings.PointExpiry = rolling(30, "DAY", false) e.settings.PointExpiry = rolling(30, "DAY", false)
c := e.member("Budi", "081234561234") c := e.member("Budi", "6281234561234")
soon, later := e.at(24*time.Hour), e.at(90*24*time.Hour) soon, later := e.at(24*time.Hour), e.at(90*24*time.Hour)
e.earnCoins(t, c, 10, soon) e.earnCoins(t, c, 10, soon)
e.earnCoins(t, c, 10, later) e.earnCoins(t, c, 10, later)
@@ -54,7 +54,7 @@ func (e *walletMoveEnv) expiry(notifier customerNotifier) (*WalletExpiryProcesso
func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) { func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
ord := earn(a, 150, e.at(-time.Hour)) ord := earn(a, 150, e.at(-time.Hour))
ord.Description = "Belanja #ORD-0098" ord.Description = "Belanja #ORD-0098"
due := e.credit(t, ord) due := e.credit(t, ord)
@@ -102,7 +102,7 @@ func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) {
func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) { func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
e.credit(t, earn(a, 150, e.at(-time.Hour))) e.credit(t, earn(a, 150, e.at(-time.Hour)))
notifier := &notifierFake{} notifier := &notifierFake{}
@@ -128,7 +128,7 @@ func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) {
func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) { func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
e.credit(t, earn(a, 150, e.at(-time.Hour))) e.credit(t, earn(a, 150, e.at(-time.Hour)))
p, repo := e.expiry(nil) p, repo := e.expiry(nil)
// A lot listed that ExpireLot cannot find. // A lot listed that ExpireLot cannot find.
@@ -142,7 +142,7 @@ func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) {
func TestWalletExpiry_NothingDue(t *testing.T) { func TestWalletExpiry_NothingDue(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
e.credit(t, earn(a, 150, e.at(time.Hour))) e.credit(t, earn(a, 150, e.at(time.Hour)))
notifier := &notifierFake{} notifier := &notifierFake{}
p, _ := e.expiry(notifier) p, _ := e.expiry(notifier)
@@ -206,7 +206,7 @@ func TestWalletExpiry_RemindsOncePerDayBeforeExpiry(t *testing.T) {
e.now = wib(2026, 10, 25, 9, 0) e.now = wib(2026, 10, 25, 9, 0)
e.settings.PointExpiry.ReminderDays = 7 e.settings.PointExpiry.ReminderDays = 7
e.settings.CoinExpiry.ReminderDays = 0 // no reminders for EnakCoin e.settings.CoinExpiry.ReminderDays = 0 // no reminders for EnakCoin
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
oct31 := wib(2026, 10, 31, 23, 59) oct31 := wib(2026, 10, 31, 23, 59)
nov30 := wib(2026, 11, 30, 23, 59) nov30 := wib(2026, 11, 30, 23, 59)
e.credit(t, earn(a, 100, &oct31)) e.credit(t, earn(a, 100, &oct31))
+8 -2
View File
@@ -2,6 +2,7 @@ package processor
import ( import (
"context" "context"
"encoding/binary"
"fmt" "fmt"
"os" "os"
"sync" "sync"
@@ -42,7 +43,7 @@ func walletMoveDB(t *testing.T) (db *gorm.DB, org, a, b uuid.UUID) {
require.NoError(t, err) require.NoError(t, err)
org, a, b = uuid.New(), uuid.New(), uuid.New() org, a, b = uuid.New(), uuid.New(), uuid.New()
phoneA, phoneB := "08"+a.String()[:10], "08"+b.String()[:10] phoneA, phoneB := walletTestPhone(a), walletTestPhone(b)
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet move test', 'basic')`, org).Error) require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet move test', 'basic')`, org).Error)
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Anita', ?), (?, ?, 'Budi Santoso', ?)`, require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Anita', ?), (?, ?, 'Budi Santoso', ?)`,
a, org, phoneA, b, org, phoneB).Error) a, org, phoneA, b, org, phoneB).Error)
@@ -117,7 +118,7 @@ func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) {
_, err := wallet.Credit(ctx, earn(b, 100, nil)) _, err := wallet.Credit(ctx, earn(b, 100, nil))
return err return err
})) }))
phone := func(id uuid.UUID) string { return "08" + id.String()[:10] } phone := walletTestPhone
const rounds = 10 const rounds = 10
errs := make(chan error, 2*rounds) errs := make(chan error, 2*rounds)
@@ -235,3 +236,8 @@ func TestWalletExpiry_TwoInstancesAgainstPostgres(t *testing.T) {
assert.Equal(t, int64(10), expires) assert.Equal(t, int64(10), expires)
assert.Zero(t, left) assert.Zero(t, left)
} }
// walletTestPhone is a phone number in its stored form, 628…, unique to the customer.
func walletTestPhone(id uuid.UUID) string {
return fmt.Sprintf("628%09d", binary.BigEndian.Uint64(id[:8])%1_000_000_000)
}
@@ -85,8 +85,8 @@ func findRow(t *testing.T, e *walletMoveEnv, customerID uuid.UUID, txType string
// redeems 30. Tracing B's redemption leads to A's order #ORD-1. // redeems 30. Tracing B's redemption leads to A's order #ORD-1.
func TestWalletTrace_RedemptionLeadsBackToTheSendersOrder(t *testing.T) { func TestWalletTrace_RedemptionLeadsBackToTheSendersOrder(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
b := e.member("Budi Santoso", "081234561234") b := e.member("Budi Santoso", "6281234561234")
ord1 := earn(a, 100, e.at(30*24*time.Hour)) ord1 := earn(a, 100, e.at(30*24*time.Hour))
ord1.Description = "Belanja #ORD-1" ord1.Description = "Belanja #ORD-1"
ord2 := earn(a, 50, e.at(60*24*time.Hour)) ord2 := earn(a, 50, e.at(60*24*time.Hour))
@@ -121,8 +121,8 @@ func TestWalletTrace_RedemptionLeadsBackToTheSendersOrder(t *testing.T) {
func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) { func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
b := e.member("Budi", "081234561234") b := e.member("Budi", "6281234561234")
e.credit(t, earn(a, 100, e.at(time.Hour))) e.credit(t, earn(a, 100, e.at(time.Hour)))
e.credit(t, earn(a, 50, nil)) e.credit(t, earn(a, 50, nil))
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
@@ -153,7 +153,7 @@ func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) {
func TestWalletTrace_OtherOrganizationsRowsAreNotFound(t *testing.T) { func TestWalletTrace_OtherOrganizationsRowsAreNotFound(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
res := e.credit(t, earn(a, 10, nil)) res := e.credit(t, earn(a, 10, nil))
_, err := NewWalletTraceProcessor(walletTraceRepoFake{e}).Trace(e.ctx, uuid.New(), res.Transaction.ID) _, err := NewWalletTraceProcessor(walletTraceRepoFake{e}).Trace(e.ctx, uuid.New(), res.Transaction.ID)
@@ -15,6 +15,7 @@ import (
"apskel-pos-be/internal/logger" "apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository" "apskel-pos-be/internal/repository"
"apskel-pos-be/internal/util"
) )
// ErrWalletRecipientNotFound means no customer of the sender's organization has the // ErrWalletRecipientNotFound means no customer of the sender's organization has the
@@ -213,10 +214,13 @@ func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UU
// them: an active customer of the same organization, not the walk-in customer, and // them: an active customer of the same organization, not the walk-in customer, and
// not the sender. // not the sender.
func (p *WalletTransferProcessor) recipient(ctx context.Context, sender *repository.WalletMoveCustomer, phoneNumber string) (*repository.WalletMoveCustomer, error) { func (p *WalletTransferProcessor) recipient(ctx context.Context, sender *repository.WalletMoveCustomer, phoneNumber string) (*repository.WalletMoveCustomer, error) {
phoneNumber = strings.TrimSpace(phoneNumber) if strings.TrimSpace(phoneNumber) == "" {
if phoneNumber == "" {
return nil, fmt.Errorf("%w: the recipient's phone number is required", ErrWalletMoveRejected) return nil, fmt.Errorf("%w: the recipient's phone number is required", ErrWalletMoveRejected)
} }
phoneNumber, err := util.NormalizePhoneNumber(phoneNumber)
if err != nil {
return nil, fmt.Errorf("%w: the recipient's phone number is not valid", ErrWalletMoveRejected)
}
recipient, err := p.customers.FindCustomerByPhone(ctx, phoneNumber) recipient, err := p.customers.FindCustomerByPhone(ctx, phoneNumber)
if errors.Is(err, repository.ErrWalletNotFound) { if errors.Is(err, repository.ErrWalletNotFound) {
return nil, ErrWalletRecipientNotFound return nil, ErrWalletRecipientNotFound
@@ -282,10 +286,14 @@ func maskName(name string) string {
return strings.Join(words, " ") return strings.Join(words, " ")
} }
// maskPhoneNumber keeps the first two and the last four digits: // maskPhoneNumber keeps the first two and the last four digits of the number as
// "081234561234" → "08**-****-1234". // customers write it, with 0 for 62: "6281234561234" → "08**-****-1234".
func maskPhoneNumber(phone string) string { func maskPhoneNumber(phone string) string {
runes := []rune(strings.TrimSpace(phone)) phone = strings.TrimSpace(phone)
if strings.HasPrefix(phone, "62") {
phone = "0" + phone[2:]
}
runes := []rune(phone)
if len(runes) < 8 { if len(runes) < 8 {
return "****" return "****"
} }
@@ -37,8 +37,8 @@ func sendPoints(amount int64, phone string) models.WalletTransfer {
func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) { func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
b := e.member("Budi Santoso", "081234561234") b := e.member("Budi Santoso", "6281234561234")
dec, jan := e.at(30*24*time.Hour), e.at(60*24*time.Hour) dec, jan := e.at(30*24*time.Hour), e.at(60*24*time.Hour)
first := e.credit(t, earn(a, 100, dec)) first := e.credit(t, earn(a, 100, dec))
second := e.credit(t, earn(a, 50, jan)) second := e.credit(t, earn(a, 50, jan))
@@ -97,8 +97,8 @@ func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) {
func TestWalletTransfer_Coins(t *testing.T) { func TestWalletTransfer_Coins(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
b := e.member("Budi", "081234561234") b := e.member("Budi", "6281234561234")
e.earnCoins(t, a, 10, nil) 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{}) _, err := e.transfers(nil).Transfer(e.ctx, a, models.WalletTransfer{Currency: "coin", Amount: 4, RecipientPhone: "081234561234"}, "482913", "key-1", models.CustomerPinRequestInfo{})
@@ -109,14 +109,14 @@ func TestWalletTransfer_Coins(t *testing.T) {
func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) { func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
e.credit(t, earn(a, 100, nil)) e.credit(t, earn(a, 100, nil))
walkIn := e.member("Walk-in", "081100000000") walkIn := e.member("Walk-in", "6281100000000")
e.customers.byID[walkIn].IsDefault = true e.customers.byID[walkIn].IsDefault = true
inactive := e.member("Old", "081100000001") inactive := e.member("Old", "6281100000001")
e.customers.byID[inactive].IsActive = false e.customers.byID[inactive].IsActive = false
elsewhere := e.member("Other Org", "081100000002") elsewhere := e.member("Other Org", "6281100000002")
e.customers.byID[elsewhere].OrganizationID = uuid.New() e.customers.byID[elsewhere].OrganizationID = uuid.New()
for phone, want := range map[string]error{ for phone, want := range map[string]error{
@@ -126,6 +126,7 @@ func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) {
"081100000002": ErrWalletRecipientNotFound, // another organization looks like nobody "081100000002": ErrWalletRecipientNotFound, // another organization looks like nobody
"081999999999": ErrWalletRecipientNotFound, "081999999999": ErrWalletRecipientNotFound,
"": ErrWalletMoveRejected, "": ErrWalletMoveRejected,
"021-1234567": ErrWalletMoveRejected, // not a mobile number
} { } {
_, err := e.transfers(nil).Recipient(e.ctx, a, phone) _, err := e.transfers(nil).Recipient(e.ctx, a, phone)
assert.ErrorIs(t, err, want, phone) assert.ErrorIs(t, err, want, phone)
@@ -138,18 +139,20 @@ func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) {
func TestWalletTransfer_RecipientIsMasked(t *testing.T) { func TestWalletTransfer_RecipientIsMasked(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
e.member("Budi Santoso", "081234561234") e.member("Budi Santoso", "6281234561234")
got, err := e.transfers(nil).Recipient(e.ctx, a, " 081234561234 ") for _, phone := range []string{" 081234561234 ", "6281234561234", "+62 812-3456-1234"} {
require.NoError(t, err) got, err := e.transfers(nil).Recipient(e.ctx, a, phone)
assert.Equal(t, &models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, got) require.NoError(t, err, phone)
assert.Equal(t, &models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, got, phone)
}
} }
func TestWalletTransfer_OrganizationLimits(t *testing.T) { func TestWalletTransfer_OrganizationLimits(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
e.member("Budi", "081234561234") e.member("Budi", "6281234561234")
e.credit(t, earn(a, 1000, nil)) e.credit(t, earn(a, 1000, nil))
e.settings.Transfer = models.LoyaltyTransferSettings{Enabled: true, MinAmount: 10, MaxPerTransaction: ptr(int64(300)), DailyLimit: ptr(int64(500))} e.settings.Transfer = models.LoyaltyTransferSettings{Enabled: true, MinAmount: 10, MaxPerTransaction: ptr(int64(300)), DailyLimit: ptr(int64(500))}
send := func(amount int64, key string) error { send := func(amount int64, key string) error {
@@ -177,8 +180,8 @@ func TestWalletTransfer_OrganizationLimits(t *testing.T) {
func TestWalletTransfer_HeldAfterPinReset(t *testing.T) { func TestWalletTransfer_HeldAfterPinReset(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
e.member("Budi", "081234561234") e.member("Budi", "6281234561234")
e.credit(t, earn(a, 100, nil)) e.credit(t, earn(a, 100, nil))
until := e.now.Add(time.Hour) until := e.now.Add(time.Hour)
e.pins.err = &PinError{Code: PinErrTransferBlocked, Until: &until} e.pins.err = &PinError{Code: PinErrTransferBlocked, Until: &until}
@@ -192,8 +195,8 @@ func TestWalletTransfer_HeldAfterPinReset(t *testing.T) {
func TestWalletTransfer_NotEnoughBalance(t *testing.T) { func TestWalletTransfer_NotEnoughBalance(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
b := e.member("Budi", "081234561234") b := e.member("Budi", "6281234561234")
e.credit(t, earn(a, 100, nil)) e.credit(t, earn(a, 100, nil))
// An expired lot cannot be sent even before the expiry job takes it. // An expired lot cannot be sent even before the expiry job takes it.
e.credit(t, earn(a, 50, e.at(-time.Hour))) e.credit(t, earn(a, 50, e.at(-time.Hour)))
@@ -205,9 +208,9 @@ func TestWalletTransfer_NotEnoughBalance(t *testing.T) {
func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) { func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) {
e := newWalletMoveEnv(t) e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678") a := e.member("Anita", "6281200005678")
b := e.member("Budi", "081234561234") b := e.member("Budi", "6281234561234")
e.member("Citra", "081255550000") e.member("Citra", "6281255550000")
e.credit(t, earn(a, 100, nil)) e.credit(t, earn(a, 100, nil))
notifier := &notifierFake{} notifier := &notifierFake{}
+41
View File
@@ -0,0 +1,41 @@
package util
import (
"errors"
"regexp"
"strings"
)
// ErrInvalidPhoneNumber means a phone number is not an Indonesian mobile number.
var ErrInvalidPhoneNumber = errors.New("invalid phone number format")
// customerPhonePattern is an Indonesian mobile number in its stored form: 62, then the
// number without its leading 0 (628…, 10 to 14 digits in all).
var customerPhonePattern = regexp.MustCompile(`^628\d{7,11}$`)
// NormalizePhoneNumber turns a customer's phone number into the one form it is stored
// and looked up in, 62…: "0812-3456-1234", "+62 812 3456 1234", "62812…" and "812…"
// all become "6281234561234". Spaces, dashes, dots and parentheses are dropped.
func NormalizePhoneNumber(raw string) (string, error) {
digits := strings.Map(func(r rune) rune {
switch r {
case ' ', '-', '.', '(', ')':
return -1
}
return r
}, strings.TrimSpace(raw))
digits = strings.TrimPrefix(digits, "+")
switch {
case strings.HasPrefix(digits, "620"):
// +62 typed in front of a number that kept its 0.
digits = "62" + digits[3:]
case strings.HasPrefix(digits, "0"):
digits = "62" + digits[1:]
case strings.HasPrefix(digits, "8"):
digits = "62" + digits
}
if !customerPhonePattern.MatchString(digits) {
return "", ErrInvalidPhoneNumber
}
return digits, nil
}
+49
View File
@@ -0,0 +1,49 @@
package util
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNormalizePhoneNumber(t *testing.T) {
for _, raw := range []string{
"6281234561234",
"+6281234561234",
"081234561234",
"81234561234",
"0812-3456-1234",
"+62 812 3456 1234",
"(+62) 812.3456.1234",
"+62081234561234",
" 081234561234 ",
} {
got, err := NormalizePhoneNumber(raw)
require.NoError(t, err, raw)
assert.Equal(t, "6281234561234", got, raw)
}
shortest, err := NormalizePhoneNumber("0811234567")
require.NoError(t, err)
assert.Equal(t, "62811234567", shortest)
longest, err := NormalizePhoneNumber("0812345678901")
require.NoError(t, err)
assert.Equal(t, "62812345678901", longest)
_, err = NormalizePhoneNumber("08123456789012")
assert.ErrorIs(t, err, ErrInvalidPhoneNumber, "too long")
for _, raw := range []string{
"",
"0",
"021-1234567", // a landline, cannot get the WhatsApp OTP
"+6521234567", // not Indonesian
"+1 415 555 0100", // not Indonesian
"62812", // too short
"0812abc61234",
"0812/3456/1234",
} {
_, err := NormalizePhoneNumber(raw)
assert.ErrorIs(t, err, ErrInvalidPhoneNumber, raw)
}
}
+14 -8
View File
@@ -9,6 +9,7 @@ import (
"apskel-pos-be/internal/constants" "apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract" "apskel-pos-be/internal/contract"
"apskel-pos-be/internal/util"
) )
type CustomerAuthValidator interface { type CustomerAuthValidator interface {
@@ -36,7 +37,7 @@ func (v *CustomerAuthValidatorImpl) ValidateCheckPhoneRequest(req *contract.Chec
return errors.New("phone number is required"), constants.ValidationErrorCode return errors.New("phone number is required"), constants.ValidationErrorCode
} }
if !v.isValidPhoneNumber(req.PhoneNumber) { if !v.normalizePhoneNumber(&req.PhoneNumber) {
return errors.New("invalid phone number format"), constants.ValidationErrorCode return errors.New("invalid phone number format"), constants.ValidationErrorCode
} }
@@ -53,7 +54,7 @@ func (v *CustomerAuthValidatorImpl) ValidateRegisterStartRequest(req *contract.R
return errors.New("phone number is required"), constants.ValidationErrorCode return errors.New("phone number is required"), constants.ValidationErrorCode
} }
if !v.isValidPhoneNumber(req.PhoneNumber) { if !v.normalizePhoneNumber(&req.PhoneNumber) {
return errors.New("invalid phone number format"), constants.ValidationErrorCode return errors.New("invalid phone number format"), constants.ValidationErrorCode
} }
@@ -161,7 +162,7 @@ func (v *CustomerAuthValidatorImpl) ValidateCustomerLoginRequest(req *contract.C
return errors.New("phone number is required"), constants.ValidationErrorCode return errors.New("phone number is required"), constants.ValidationErrorCode
} }
if !v.isValidPhoneNumber(req.PhoneNumber) { if !v.normalizePhoneNumber(&req.PhoneNumber) {
return errors.New("invalid phone number format"), constants.ValidationErrorCode return errors.New("invalid phone number format"), constants.ValidationErrorCode
} }
@@ -174,10 +175,15 @@ func (v *CustomerAuthValidatorImpl) ValidateCustomerLoginRequest(req *contract.C
} }
// Helper validation functions // Helper validation functions
func (v *CustomerAuthValidatorImpl) isValidPhoneNumber(phoneNumber string) bool { // normalizePhoneNumber rewrites the phone number in the form it is stored in, 62…
// Basic phone number validation - adjust regex based on your requirements // (util.NormalizePhoneNumber), and reports false when it is not a valid one.
phoneRegex := regexp.MustCompile(`^\+?[1-9]\d{1,14}$`) func (v *CustomerAuthValidatorImpl) normalizePhoneNumber(phoneNumber *string) bool {
return phoneRegex.MatchString(phoneNumber) normalized, err := util.NormalizePhoneNumber(*phoneNumber)
if err != nil {
return false
}
*phoneNumber = normalized
return true
} }
func (v *CustomerAuthValidatorImpl) isValidDateFormat(date string) bool { func (v *CustomerAuthValidatorImpl) isValidDateFormat(date string) bool {
@@ -208,7 +214,7 @@ func (v *CustomerAuthValidatorImpl) ValidateResendOtpRequest(req *contract.Resen
} }
// Validate phone number format // Validate phone number format
if !v.isValidPhoneNumber(req.PhoneNumber) { if !v.normalizePhoneNumber(&req.PhoneNumber) {
return errors.New("invalid phone number format"), constants.CustomerEntity return errors.New("invalid phone number format"), constants.CustomerEntity
} }
@@ -0,0 +1,2 @@
-- Nothing to undo: the forms the numbers were stored in before are not kept, and 62… is
-- what the code before this migration accepted too.
@@ -0,0 +1,62 @@
-- Customer phone numbers are stored in one form, 62… without + (util.NormalizePhoneNumber),
-- and every number a customer types is rewritten to it before it is looked up. This
-- rewrites the numbers stored before, so 0812…, +62 812… and 812… become 62812….
--
-- Only one customer may have a number. When several stored numbers become the same one,
-- the customer that already has it keeps it, else a registered one (with a password),
-- else the oldest; the others are left as they are. A number that is not an Indonesian
-- mobile number is left as it is too. Neither can log in until it is fixed by hand:
--
-- SELECT id, name, phone_number FROM customers
-- WHERE phone_number IS NOT NULL AND phone_number !~ '^628[0-9]{7,11}$';
WITH stripped AS (
SELECT id, phone_number, password_hash, created_at,
regexp_replace(regexp_replace(phone_number, '[[:space:]().-]', '', 'g'), '^\+', '') AS p
FROM customers
WHERE phone_number IS NOT NULL
),
normalized AS (
SELECT id, phone_number, password_hash, created_at,
CASE
WHEN p LIKE '620%' THEN '62' || substr(p, 4)
WHEN p LIKE '0%' THEN '62' || substr(p, 2)
WHEN p LIKE '8%' THEN '62' || p
ELSE p
END AS new_phone
FROM stripped
),
ranked AS (
SELECT id, new_phone,
row_number() OVER (
PARTITION BY new_phone
ORDER BY phone_number = new_phone DESC, password_hash IS NOT NULL DESC, created_at, id
) AS rank
FROM normalized
WHERE new_phone ~ '^628[0-9]{7,11}$'
)
UPDATE customers c
SET phone_number = r.new_phone, updated_at = NOW()
FROM ranked r
WHERE c.id = r.id AND r.rank = 1 AND c.phone_number <> r.new_phone;
-- A registration started before this migration finishes with the number in its OTP
-- session, and a resent OTP is found by it.
UPDATE otp_sessions
SET phone_number = n.new_phone, updated_at = NOW()
FROM (
SELECT id,
CASE
WHEN p LIKE '620%' THEN '62' || substr(p, 4)
WHEN p LIKE '0%' THEN '62' || substr(p, 2)
WHEN p LIKE '8%' THEN '62' || p
ELSE p
END AS new_phone
FROM (
SELECT id, regexp_replace(regexp_replace(phone_number, '[[:space:]().-]', '', 'g'), '^\+', '') AS p
FROM otp_sessions
) stripped
) n
WHERE otp_sessions.id = n.id
AND n.new_phone ~ '^628[0-9]{7,11}$'
AND otp_sessions.phone_number <> n.new_phone;