A customer's phone number (customers.phone_number, the one they log in with) has one stored form, 62 followed by the number without its 0: 6281234561234. util.NormalizePhoneNumber rewrites 0812…, +62 812…, 62812…, 812… and +62 0812…, with spaces, dashes, dots or parentheses, to it, and refuses anything that is not an Indonesian mobile number (628 and 7 to 11 more digits). It is applied wherever a customer types their number: check-phone, register, login and resend-OTP (the validator rewrites the request), and the transfer recipient. Before, the number was matched as typed and a leading 0 was refused, so the same customer written another way was not found. The masked recipient stays 08**-****-1234 as customers write numbers. Migration 000116 rewrites the numbers already stored in customers and otp_sessions. When several become the same number, the customer that already has it keeps it, else a registered one, else the oldest; the others, and numbers that are not Indonesian mobile numbers, are left as they are and cannot log in until fixed by hand (the query to find them is in the migration). Down does nothing. The migration was run, twice, against Postgres with a reduced customers and otp_sessions schema and sample numbers; not against the real schema. The Postgres tests were not run. customers.phone (the POS contact number) is not touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
8.3 KiB
Go
245 lines
8.3 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"apskel-pos-be/internal/constants"
|
|
"apskel-pos-be/internal/entities"
|
|
"apskel-pos-be/internal/repository"
|
|
)
|
|
|
|
// walletExpiryRepoFake lists the fake wallet's due lots the way the query does, plus
|
|
// any extra lots a test wants listed.
|
|
type walletExpiryRepoFake struct {
|
|
wallet *walletRepoFake
|
|
extra []repository.DueLot
|
|
// customer/currency/date of the reminders recorded.
|
|
reminded map[string]bool
|
|
}
|
|
|
|
func (f *walletExpiryRepoFake) ListDueLots(_ context.Context, asOf time.Time, limit int) ([]repository.DueLot, error) {
|
|
descriptions := map[uuid.UUID]string{}
|
|
for _, tx := range f.wallet.transactions {
|
|
descriptions[tx.ID] = tx.Description
|
|
}
|
|
due := append([]repository.DueLot(nil), f.extra...)
|
|
for _, lot := range f.wallet.lots {
|
|
if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && !lot.ExpiresAt.After(asOf) {
|
|
due = append(due, repository.DueLot{
|
|
ID: lot.ID, CustomerID: lot.CustomerID, Currency: lot.Currency, Remaining: lot.RemainingAmount,
|
|
ExpiresAt: *lot.ExpiresAt, SourceDescription: descriptions[lot.SourceTransactionID],
|
|
})
|
|
}
|
|
}
|
|
sort.SliceStable(due, func(i, j int) bool { return due[i].ExpiresAt.Before(due[j].ExpiresAt) })
|
|
if len(due) > limit {
|
|
due = due[:limit]
|
|
}
|
|
return due, nil
|
|
}
|
|
|
|
func (e *walletMoveEnv) expiry(notifier customerNotifier) (*WalletExpiryProcessor, *walletExpiryRepoFake) {
|
|
repo := &walletExpiryRepoFake{wallet: e.repo}
|
|
p := NewWalletExpiryProcessor(repo, e, e.p, txRunnerFake{}, notifier)
|
|
p.now = func() time.Time { return e.now }
|
|
return p, repo
|
|
}
|
|
|
|
func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) {
|
|
e := newWalletMoveEnv(t)
|
|
a := e.member("Anita", "6281200005678")
|
|
ord := earn(a, 150, e.at(-time.Hour))
|
|
ord.Description = "Belanja #ORD-0098"
|
|
due := e.credit(t, ord)
|
|
e.credit(t, earn(a, 50, e.at(-2*time.Hour)))
|
|
e.credit(t, earn(a, 70, e.at(time.Hour))) // not yet
|
|
e.credit(t, earn(a, 30, nil)) // never
|
|
e.earnCoins(t, a, 4, e.at(-time.Minute))
|
|
// A payment already used part of the first lot; only the rest expires.
|
|
_, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: redeem(a, 20).WalletEntry, PreferredLotIDs: []uuid.UUID{due.Lots[0].ID}})
|
|
require.NoError(t, err)
|
|
|
|
notifier := ¬ifierFake{}
|
|
p, _ := e.expiry(notifier)
|
|
count, err := p.ExpireDue(e.ctx)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 3, count)
|
|
assert.Equal(t, int64(100), e.balance(t, a), "70 not due yet + 30 that never expires")
|
|
assert.Equal(t, int64(0), e.coinBalance(t, a))
|
|
|
|
var expire *entities.WalletTransaction
|
|
for _, tx := range e.repo.transactions {
|
|
if tx.Type == constants.WalletTxTypeExpire && tx.ReferenceID == due.Lots[0].ID {
|
|
expire = tx
|
|
}
|
|
}
|
|
require.NotNil(t, expire)
|
|
assert.Equal(t, int64(-130), expire.Amount)
|
|
assert.Equal(t, "Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", expire.Description)
|
|
assert.Equal(t, constants.WalletRefTypeLot, expire.ReferenceType)
|
|
assert.Equal(t, "expire:"+due.Lots[0].ID.String(), *expire.IdempotencyKey)
|
|
|
|
// One push per currency, with the total.
|
|
pushes := notifier.pushes[a]
|
|
require.Len(t, pushes, 2)
|
|
byCurrency := map[string]pushFake{}
|
|
for _, p := range pushes {
|
|
byCurrency[p.data["currency"]] = p
|
|
}
|
|
assert.Equal(t, "EnakPoint kedaluwarsa", byCurrency["POINT"].title)
|
|
assert.Equal(t, "180 EnakPoint kamu sudah kedaluwarsa.", byCurrency["POINT"].body)
|
|
assert.Equal(t, NotificationTypeWalletExpired, byCurrency["POINT"].data["type"])
|
|
assert.Equal(t, "4", byCurrency["COIN"].data["amount"])
|
|
}
|
|
|
|
func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) {
|
|
e := newWalletMoveEnv(t)
|
|
a := e.member("Anita", "6281200005678")
|
|
e.credit(t, earn(a, 150, e.at(-time.Hour)))
|
|
notifier := ¬ifierFake{}
|
|
|
|
// Two instances, one after the other.
|
|
first, _ := e.expiry(notifier)
|
|
second, _ := e.expiry(notifier)
|
|
n1, err := first.ExpireDue(e.ctx)
|
|
require.NoError(t, err)
|
|
n2, err := second.ExpireDue(e.ctx)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 1, n1)
|
|
assert.Equal(t, 0, n2)
|
|
assert.Len(t, notifier.pushes[a], 1)
|
|
var expires int
|
|
for _, tx := range e.repo.transactions {
|
|
if tx.Type == constants.WalletTxTypeExpire {
|
|
expires++
|
|
}
|
|
}
|
|
assert.Equal(t, 1, expires)
|
|
}
|
|
|
|
func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) {
|
|
e := newWalletMoveEnv(t)
|
|
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.
|
|
repo.extra = []repository.DueLot{{ID: uuid.New(), CustomerID: a, Currency: "POINT", Remaining: 5, ExpiresAt: e.now.Add(-3 * time.Hour)}}
|
|
|
|
count, err := p.ExpireDue(e.ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, count)
|
|
assert.Equal(t, int64(0), e.balance(t, a))
|
|
}
|
|
|
|
func TestWalletExpiry_NothingDue(t *testing.T) {
|
|
e := newWalletMoveEnv(t)
|
|
a := e.member("Anita", "6281200005678")
|
|
e.credit(t, earn(a, 150, e.at(time.Hour)))
|
|
notifier := ¬ifierFake{}
|
|
p, _ := e.expiry(notifier)
|
|
|
|
count, err := p.ExpireDue(e.ctx)
|
|
require.NoError(t, err)
|
|
assert.Zero(t, count)
|
|
assert.Empty(t, notifier.pushes)
|
|
}
|
|
|
|
func (f *walletExpiryRepoFake) OrganizationsWithUpcomingExpiry(_ context.Context, asOf time.Time) ([]uuid.UUID, error) {
|
|
seen := map[uuid.UUID]bool{}
|
|
var out []uuid.UUID
|
|
for _, lot := range f.wallet.lots {
|
|
if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && lot.ExpiresAt.After(asOf) && !seen[lot.OrganizationID] {
|
|
seen[lot.OrganizationID] = true
|
|
out = append(out, lot.OrganizationID)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *walletExpiryRepoFake) UpcomingUnreminded(_ context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]repository.UpcomingExpiry, error) {
|
|
sums := map[[2]string]int64{}
|
|
var order [][2]string
|
|
for _, lot := range f.wallet.lots {
|
|
if lot.OrganizationID != organizationID || lot.Currency != currency || lot.RemainingAmount == 0 ||
|
|
lot.ExpiresAt == nil || !lot.ExpiresAt.After(asOf) || lot.ExpiresAt.After(until) {
|
|
continue
|
|
}
|
|
key := [2]string{lot.CustomerID.String(), lot.ExpiresAt.In(walletDisplayLocation).Format("2006-01-02")}
|
|
if f.reminded[key[0]+"/"+currency+"/"+key[1]] {
|
|
continue
|
|
}
|
|
if _, ok := sums[key]; !ok {
|
|
order = append(order, key)
|
|
}
|
|
sums[key] += lot.RemainingAmount
|
|
}
|
|
var out []repository.UpcomingExpiry
|
|
for _, key := range order {
|
|
out = append(out, repository.UpcomingExpiry{CustomerID: uuid.MustParse(key[0]), Date: key[1], Amount: sums[key]})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *walletExpiryRepoFake) MarkReminded(_ context.Context, u repository.UpcomingExpiry, currency string) (bool, error) {
|
|
if f.reminded == nil {
|
|
f.reminded = map[string]bool{}
|
|
}
|
|
key := u.CustomerID.String() + "/" + currency + "/" + u.Date
|
|
if f.reminded[key] {
|
|
return false, nil
|
|
}
|
|
f.reminded[key] = true
|
|
return true, nil
|
|
}
|
|
|
|
func TestWalletExpiry_RemindsOncePerDayBeforeExpiry(t *testing.T) {
|
|
e := newWalletMoveEnv(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", "6281200005678")
|
|
oct31 := wib(2026, 10, 31, 23, 59)
|
|
nov30 := wib(2026, 11, 30, 23, 59)
|
|
e.credit(t, earn(a, 100, &oct31))
|
|
e.credit(t, earn(a, 50, &oct31))
|
|
e.credit(t, earn(a, 70, &nov30)) // too far off yet
|
|
e.earnCoins(t, a, 5, &oct31)
|
|
notifier := ¬ifierFake{}
|
|
p, _ := e.expiry(notifier)
|
|
|
|
sent, err := p.SendReminders(e.ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, sent)
|
|
require.Len(t, notifier.pushes[a], 1)
|
|
push := notifier.pushes[a][0]
|
|
assert.Equal(t, "EnakPoint akan kedaluwarsa", push.title)
|
|
assert.Equal(t, "150 EnakPoint akan kedaluwarsa pada 31 Okt 2026. Pakai sebelum hangus.", push.body)
|
|
assert.Equal(t, map[string]string{"type": NotificationTypeWalletExpiring, "currency": "POINT", "amount": "150", "expiry_date": "2026-10-31"}, push.data)
|
|
|
|
// The next run, on this instance or another, sends nothing again.
|
|
again, err := p.SendReminders(e.ctx)
|
|
require.NoError(t, err)
|
|
assert.Zero(t, again)
|
|
|
|
// Once 30 Nov comes within seven days, it gets its own reminder.
|
|
e.now = wib(2026, 11, 23, 9, 0)
|
|
sent, err = p.SendReminders(e.ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, sent)
|
|
assert.Equal(t, "70", notifier.pushes[a][1].data["amount"])
|
|
}
|
|
|
|
func TestFormatWalletDate(t *testing.T) {
|
|
assert.Equal(t, "31 Okt 2026", formatWalletDate("2026-10-31"))
|
|
assert.Equal(t, "1 Mei 2027", formatWalletDate("2027-05-01"))
|
|
}
|