package processor import ( "errors" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) type messengerFake struct{ sent map[string][]string } func (f *messengerFake) SendWhatsAppMessage(phone, message string) error { if f.sent == nil { f.sent = map[string][]string{} } f.sent[phone] = append(f.sent[phone], message) return nil } func sendPoints(amount int64, phone string) models.WalletTransfer { return models.WalletTransfer{Currency: constants.WalletCurrencyPoint, Amount: amount, RecipientPhone: phone} } func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) { e := newWalletMoveEnv(t) a := e.member("Anita", "081200005678") b := e.member("Budi Santoso", "081234561234") 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)) messenger := &messengerFake{} // The example in ยง8: A sends 120, 100 from the lot expiring first and 20 from the next. res, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) require.NoError(t, err) assert.Equal(t, int64(30), res.Balance) assert.Equal(t, models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, res.Recipient) assert.Equal(t, []models.WalletMovedLot{{Amount: 100, ExpiresAt: dec}, {Amount: 20, ExpiresAt: jan}}, res.Lots) assert.Equal(t, int64(120), e.balance(t, b)) assert.Equal(t, []PinAction{PinActionTransfer}, e.pins.actions) var bLots []uuid.UUID for _, lot := range e.repo.lots { if lot.CustomerID == b { require.NotNil(t, lot.OriginLotID) bLots = append(bLots, *lot.OriginLotID) for _, origin := range e.repo.lots { if origin.ID == *lot.OriginLotID { assert.Equal(t, origin.ExpiresAt, lot.ExpiresAt, "the recipient's lot expires exactly when the sender's did") } } } } assert.Equal(t, []uuid.UUID{first.Lots[0].ID, second.Lots[0].ID}, bLots) out, in := e.repo.transactions[2], e.repo.transactions[3] assert.Equal(t, constants.WalletTxTypeTransferOut, out.Type) assert.Equal(t, b, *out.CounterpartyCustomerID) assert.Equal(t, in.ID, out.ReferenceID) assert.Equal(t, "Transfer ke Bu*** Sa*** (08**-****-1234)", out.Description) assert.Equal(t, constants.WalletTxTypeTransferIn, in.Type) assert.Equal(t, a, *in.CounterpartyCustomerID) assert.Equal(t, out.ID, in.ReferenceID) assert.Equal(t, *out.GroupID, *in.GroupID) assert.Equal(t, "Transfer dari An*** (08**-****-5678)", in.Description) assert.GreaterOrEqual(t, e.repo.locks[a], 1) assert.GreaterOrEqual(t, e.repo.locks[b], 1) assert.Equal(t, []string{"Kamu menerima 120 EnakPoint dari An*** (08**-****-5678). Cek riwayatnya di aplikasi."}, messenger.sent["081234561234"]) } func TestWalletTransfer_Coins(t *testing.T) { e := newWalletMoveEnv(t) a := e.member("Anita", "081200005678") b := e.member("Budi", "081234561234") e.earnCoins(t, a, 10, nil) _, err := e.transfers(nil).Transfer(e.ctx, a, models.WalletTransfer{Currency: "coin", Amount: 4, RecipientPhone: "081234561234"}, "482913", "key-1", models.CustomerPinRequestInfo{}) require.NoError(t, err) assert.Equal(t, int64(6), e.coinBalance(t, a)) assert.Equal(t, int64(4), e.coinBalance(t, b)) } func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) { e := newWalletMoveEnv(t) a := e.member("Anita", "081200005678") e.credit(t, earn(a, 100, nil)) walkIn := e.member("Walk-in", "081100000000") e.customers.byID[walkIn].IsDefault = true inactive := e.member("Old", "081100000001") e.customers.byID[inactive].IsActive = false elsewhere := e.member("Other Org", "081100000002") e.customers.byID[elsewhere].OrganizationID = uuid.New() for phone, want := range map[string]error{ "081200005678": ErrWalletMoveRejected, // herself "081100000000": ErrWalletMoveRejected, // the walk-in customer "081100000001": ErrWalletMoveRejected, // inactive "081100000002": ErrWalletRecipientNotFound, // another organization looks like nobody "081999999999": ErrWalletRecipientNotFound, "": ErrWalletMoveRejected, } { _, err := e.transfers(nil).Recipient(e.ctx, a, phone) assert.ErrorIs(t, err, want, phone) _, err = e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, phone), "482913", "key-"+phone, models.CustomerPinRequestInfo{}) assert.ErrorIs(t, err, want, phone) } assert.Empty(t, e.pins.actions, "refused before the PIN") assert.Equal(t, int64(100), e.balance(t, a)) } func TestWalletTransfer_RecipientIsMasked(t *testing.T) { e := newWalletMoveEnv(t) a := e.member("Anita", "081200005678") e.member("Budi Santoso", "081234561234") 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) } func TestWalletTransfer_OrganizationLimits(t *testing.T) { e := newWalletMoveEnv(t) a := e.member("Anita", "081200005678") e.member("Budi", "081234561234") e.credit(t, earn(a, 1000, nil)) e.settings.Transfer = models.LoyaltyTransferSettings{Enabled: true, MinAmount: 10, MaxPerTransaction: ptr(int64(300)), DailyLimit: ptr(int64(500))} send := func(amount int64, key string) error { _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(amount, "081234561234"), "482913", key, models.CustomerPinRequestInfo{}) return err } assert.ErrorIs(t, send(9, "below-min"), ErrWalletMoveRejected) assert.ErrorIs(t, send(301, "above-max"), ErrWalletMoveRejected) require.NoError(t, send(300, "k1")) require.NoError(t, send(200, "k2")) // The daily limit is used up; a retry of a transfer already made still replays. assert.ErrorIs(t, send(10, "k3"), ErrWalletMoveRejected) require.NoError(t, send(200, "k2")) // It starts over the next day in the customer's time zone. e.now = startOfWalletDay(e.now).AddDate(0, 0, 1).Add(time.Minute) e.repo.clock = e.now require.NoError(t, send(10, "k4")) assert.Equal(t, int64(490), e.balance(t, a)) e.settings.Transfer.Enabled = false assert.ErrorIs(t, send(10, "k5"), ErrWalletMoveRejected) } func TestWalletTransfer_HeldAfterPinReset(t *testing.T) { e := newWalletMoveEnv(t) a := e.member("Anita", "081200005678") e.member("Budi", "081234561234") e.credit(t, earn(a, 100, nil)) until := e.now.Add(time.Hour) e.pins.err = &PinError{Code: PinErrTransferBlocked, Until: &until} _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) var pinErr *PinError require.True(t, errors.As(err, &pinErr)) assert.Equal(t, PinErrTransferBlocked, pinErr.Code) assert.Equal(t, int64(100), e.balance(t, a)) } func TestWalletTransfer_NotEnoughBalance(t *testing.T) { e := newWalletMoveEnv(t) a := e.member("Anita", "081200005678") b := e.member("Budi", "081234561234") e.credit(t, earn(a, 100, nil)) // An expired lot cannot be sent even before the expiry job takes it. e.credit(t, earn(a, 50, e.at(-time.Hour))) _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) assert.ErrorIs(t, err, ErrWalletMoveRejected) assert.Equal(t, int64(0), e.balance(t, b)) } func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) { e := newWalletMoveEnv(t) a := e.member("Anita", "081200005678") b := e.member("Budi", "081234561234") e.member("Citra", "081255550000") e.credit(t, earn(a, 100, nil)) messenger := &messengerFake{} first, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) require.NoError(t, err) again, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) require.NoError(t, err) assert.True(t, again.Replayed) assert.Equal(t, first.GroupID, again.GroupID) assert.Equal(t, int64(40), e.balance(t, b)) assert.Len(t, messenger.sent["081234561234"], 1) // The same key to someone else is not a retry. _, err = e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081255550000"), "482913", "key-1", models.CustomerPinRequestInfo{}) assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) } func TestWalletTransfer_UnknownSender(t *testing.T) { e := newWalletMoveEnv(t) _, err := e.transfers(nil).Transfer(e.ctx, uuid.New(), sendPoints(1, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) assert.ErrorIs(t, err, repository.ErrWalletNotFound) } func TestMaskName(t *testing.T) { assert.Equal(t, "Bu*** Sa***", maskName("Budi Santoso")) assert.Equal(t, "An***", maskName("Anita")) assert.Equal(t, "A*** Ra***", maskName("Al Rahman")) assert.Equal(t, "***", maskName(" ")) assert.Equal(t, "08**-****-1234", maskPhoneNumber("081234561234")) assert.Equal(t, "+6**-****-1234", maskPhoneNumber("+6281234561234")) assert.Equal(t, "****", maskPhoneNumber("12345")) }