Feat/enakgame #46
@@ -152,7 +152,7 @@ func (f *movePinFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, acti
|
||||
|
||||
func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi Santoso", "081234561234")
|
||||
c := e.member("Budi Santoso", "6281234561234")
|
||||
e.earnCoins(t, c, 50, nil)
|
||||
|
||||
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) {
|
||||
e := newWalletMoveEnv(t)
|
||||
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)
|
||||
|
||||
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) {
|
||||
e := newWalletMoveEnv(t)
|
||||
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)
|
||||
|
||||
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) {
|
||||
e := newWalletMoveEnv(t)
|
||||
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)
|
||||
first := e.earnCoins(t, c, 15, soon)
|
||||
second := e.earnCoins(t, c, 15, later)
|
||||
@@ -268,7 +268,7 @@ func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) {
|
||||
func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
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, nil)
|
||||
|
||||
@@ -281,7 +281,7 @@ func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) {
|
||||
|
||||
func TestWalletExchange_NotEnoughCoins(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi", "081234561234")
|
||||
c := e.member("Budi", "6281234561234")
|
||||
e.earnCoins(t, c, 5, nil)
|
||||
|
||||
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) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi", "081234561234")
|
||||
c := e.member("Budi", "6281234561234")
|
||||
e.earnCoins(t, c, 5, nil)
|
||||
|
||||
_, 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) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi", "081234561234")
|
||||
c := e.member("Budi", "6281234561234")
|
||||
e.earnCoins(t, c, 100, nil)
|
||||
|
||||
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) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi", "081234561234")
|
||||
c := e.member("Budi", "6281234561234")
|
||||
e.earnCoins(t, c, 5, nil)
|
||||
|
||||
_, 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.now = wib(2026, 6, 1, 10, 0)
|
||||
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)
|
||||
e.earnCoins(t, c, 10, soon)
|
||||
e.earnCoins(t, c, 10, later)
|
||||
|
||||
@@ -54,7 +54,7 @@ func (e *walletMoveEnv) expiry(notifier customerNotifier) (*WalletExpiryProcesso
|
||||
|
||||
func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
ord := earn(a, 150, e.at(-time.Hour))
|
||||
ord.Description = "Belanja #ORD-0098"
|
||||
due := e.credit(t, ord)
|
||||
@@ -102,7 +102,7 @@ func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) {
|
||||
|
||||
func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
e.credit(t, earn(a, 150, e.at(-time.Hour)))
|
||||
notifier := ¬ifierFake{}
|
||||
|
||||
@@ -128,7 +128,7 @@ func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) {
|
||||
|
||||
func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
e.credit(t, earn(a, 150, e.at(-time.Hour)))
|
||||
p, repo := e.expiry(nil)
|
||||
// A lot listed that ExpireLot cannot find.
|
||||
@@ -142,7 +142,7 @@ func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) {
|
||||
|
||||
func TestWalletExpiry_NothingDue(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
e.credit(t, earn(a, 150, e.at(time.Hour)))
|
||||
notifier := ¬ifierFake{}
|
||||
p, _ := e.expiry(notifier)
|
||||
@@ -206,7 +206,7 @@ func TestWalletExpiry_RemindsOncePerDayBeforeExpiry(t *testing.T) {
|
||||
e.now = wib(2026, 10, 25, 9, 0)
|
||||
e.settings.PointExpiry.ReminderDays = 7
|
||||
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)
|
||||
nov30 := wib(2026, 11, 30, 23, 59)
|
||||
e.credit(t, earn(a, 100, &oct31))
|
||||
|
||||
@@ -2,6 +2,7 @@ package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -42,7 +43,7 @@ func walletMoveDB(t *testing.T) (db *gorm.DB, org, a, b uuid.UUID) {
|
||||
require.NoError(t, err)
|
||||
|
||||
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 customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Anita', ?), (?, ?, 'Budi Santoso', ?)`,
|
||||
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))
|
||||
return err
|
||||
}))
|
||||
phone := func(id uuid.UUID) string { return "08" + id.String()[:10] }
|
||||
phone := walletTestPhone
|
||||
|
||||
const rounds = 10
|
||||
errs := make(chan error, 2*rounds)
|
||||
@@ -235,3 +236,8 @@ func TestWalletExpiry_TwoInstancesAgainstPostgres(t *testing.T) {
|
||||
assert.Equal(t, int64(10), expires)
|
||||
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.
|
||||
func TestWalletTrace_RedemptionLeadsBackToTheSendersOrder(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi Santoso", "081234561234")
|
||||
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))
|
||||
@@ -121,8 +121,8 @@ func TestWalletTrace_RedemptionLeadsBackToTheSendersOrder(t *testing.T) {
|
||||
|
||||
func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi", "081234561234")
|
||||
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{})
|
||||
@@ -153,7 +153,7 @@ func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) {
|
||||
|
||||
func TestWalletTrace_OtherOrganizationsRowsAreNotFound(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
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)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// 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
|
||||
// not the sender.
|
||||
func (p *WalletTransferProcessor) recipient(ctx context.Context, sender *repository.WalletMoveCustomer, phoneNumber string) (*repository.WalletMoveCustomer, error) {
|
||||
phoneNumber = strings.TrimSpace(phoneNumber)
|
||||
if phoneNumber == "" {
|
||||
if strings.TrimSpace(phoneNumber) == "" {
|
||||
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)
|
||||
if errors.Is(err, repository.ErrWalletNotFound) {
|
||||
return nil, ErrWalletRecipientNotFound
|
||||
@@ -282,10 +286,14 @@ func maskName(name string) string {
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
// maskPhoneNumber keeps the first two and the last four digits:
|
||||
// "081234561234" → "08**-****-1234".
|
||||
// maskPhoneNumber keeps the first two and the last four digits of the number as
|
||||
// customers write it, with 0 for 62: "6281234561234" → "08**-****-1234".
|
||||
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 {
|
||||
return "****"
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ func sendPoints(amount int64, phone string) models.WalletTransfer {
|
||||
|
||||
func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi Santoso", "081234561234")
|
||||
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))
|
||||
@@ -97,8 +97,8 @@ func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) {
|
||||
|
||||
func TestWalletTransfer_Coins(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi", "081234561234")
|
||||
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{})
|
||||
@@ -109,14 +109,14 @@ func TestWalletTransfer_Coins(t *testing.T) {
|
||||
|
||||
func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
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
|
||||
inactive := e.member("Old", "081100000001")
|
||||
inactive := e.member("Old", "6281100000001")
|
||||
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()
|
||||
|
||||
for phone, want := range map[string]error{
|
||||
@@ -126,6 +126,7 @@ func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) {
|
||||
"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)
|
||||
@@ -138,18 +139,20 @@ func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) {
|
||||
|
||||
func TestWalletTransfer_RecipientIsMasked(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
e.member("Budi Santoso", "081234561234")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
e.member("Budi Santoso", "6281234561234")
|
||||
|
||||
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)
|
||||
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", "081200005678")
|
||||
e.member("Budi", "081234561234")
|
||||
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 {
|
||||
@@ -177,8 +180,8 @@ func TestWalletTransfer_OrganizationLimits(t *testing.T) {
|
||||
|
||||
func TestWalletTransfer_HeldAfterPinReset(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
e.member("Budi", "081234561234")
|
||||
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}
|
||||
@@ -192,8 +195,8 @@ func TestWalletTransfer_HeldAfterPinReset(t *testing.T) {
|
||||
|
||||
func TestWalletTransfer_NotEnoughBalance(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi", "081234561234")
|
||||
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)))
|
||||
@@ -205,9 +208,9 @@ func TestWalletTransfer_NotEnoughBalance(t *testing.T) {
|
||||
|
||||
func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi", "081234561234")
|
||||
e.member("Citra", "081255550000")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
b := e.member("Budi", "6281234561234")
|
||||
e.member("Citra", "6281255550000")
|
||||
e.credit(t, earn(a, 100, nil))
|
||||
notifier := ¬ifierFake{}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
type CustomerAuthValidator interface {
|
||||
@@ -36,7 +37,7 @@ func (v *CustomerAuthValidatorImpl) ValidateCheckPhoneRequest(req *contract.Chec
|
||||
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
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ func (v *CustomerAuthValidatorImpl) ValidateRegisterStartRequest(req *contract.R
|
||||
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
|
||||
}
|
||||
|
||||
@@ -161,7 +162,7 @@ func (v *CustomerAuthValidatorImpl) ValidateCustomerLoginRequest(req *contract.C
|
||||
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
|
||||
}
|
||||
|
||||
@@ -174,10 +175,15 @@ func (v *CustomerAuthValidatorImpl) ValidateCustomerLoginRequest(req *contract.C
|
||||
}
|
||||
|
||||
// Helper validation functions
|
||||
func (v *CustomerAuthValidatorImpl) isValidPhoneNumber(phoneNumber string) bool {
|
||||
// Basic phone number validation - adjust regex based on your requirements
|
||||
phoneRegex := regexp.MustCompile(`^\+?[1-9]\d{1,14}$`)
|
||||
return phoneRegex.MatchString(phoneNumber)
|
||||
// normalizePhoneNumber rewrites the phone number in the form it is stored in, 62…
|
||||
// (util.NormalizePhoneNumber), and reports false when it is not a valid one.
|
||||
func (v *CustomerAuthValidatorImpl) normalizePhoneNumber(phoneNumber *string) bool {
|
||||
normalized, err := util.NormalizePhoneNumber(*phoneNumber)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
*phoneNumber = normalized
|
||||
return true
|
||||
}
|
||||
|
||||
func (v *CustomerAuthValidatorImpl) isValidDateFormat(date string) bool {
|
||||
@@ -208,7 +214,7 @@ func (v *CustomerAuthValidatorImpl) ValidateResendOtpRequest(req *contract.Resen
|
||||
}
|
||||
|
||||
// Validate phone number format
|
||||
if !v.isValidPhoneNumber(req.PhoneNumber) {
|
||||
if !v.normalizePhoneNumber(&req.PhoneNumber) {
|
||||
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;
|
||||
Reference in New Issue
Block a user