Feat/enakgame #46
@@ -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 := ¬ifierFake{}
|
notifier := ¬ifierFake{}
|
||||||
|
|
||||||
@@ -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 := ¬ifierFake{}
|
notifier := ¬ifierFake{}
|
||||||
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))
|
||||||
|
|||||||
@@ -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 := ¬ifierFake{}
|
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/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;
|
||||||
Reference in New Issue
Block a user