diff --git a/internal/app/app.go b/internal/app/app.go index 90e216d..68615a5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -394,6 +394,7 @@ type processors struct { paymentCodeProcessor *processor.PaymentCodeProcessor pointPaymentProcessor *processor.PointPaymentProcessor walletExchangeProcessor *processor.WalletExchangeProcessor + walletTransferProcessor *processor.WalletTransferProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -415,6 +416,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor) // Exchange EnakCoin into EnakPoint, approved by the customer's PIN (docs/prd-point-coin.md F4) walletExchangeProcessor := processor.NewWalletExchangeProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) + // Send EnakPoint or EnakCoin to another customer; the recipient is told by WhatsApp (docs/prd-point-coin.md F5) + walletTransferProcessor := processor.NewWalletTransferProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, otpProcessor) return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), @@ -468,6 +471,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor paymentCodeProcessor: paymentCodeProcessor, pointPaymentProcessor: pointPaymentProcessor, walletExchangeProcessor: walletExchangeProcessor, + walletTransferProcessor: walletTransferProcessor, walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), } } @@ -603,7 +607,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor), pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor), customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor), - customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor), + customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor), } } diff --git a/internal/contract/wallet_contract.go b/internal/contract/wallet_contract.go index 9d7d133..d261a95 100644 --- a/internal/contract/wallet_contract.go +++ b/internal/contract/wallet_contract.go @@ -19,3 +19,13 @@ type ExchangeCoinsRequest struct { Coins int64 `json:"coins" binding:"required,min=1"` Pin string `json:"pin" binding:"required"` } + +// TransferWalletRequest is POST /customer/wallet/transfer (docs/prd-point-coin.md F5). +// The Idempotency-Key header is required. +type TransferWalletRequest struct { + // POINT or COIN. + Currency string `json:"currency" binding:"required"` + Amount int64 `json:"amount" binding:"required,min=1"` + RecipientPhone string `json:"recipient_phone" binding:"required"` + Pin string `json:"pin" binding:"required"` +} diff --git a/internal/handler/customer_wallet_handler.go b/internal/handler/customer_wallet_handler.go index 2faffbb..90aed18 100644 --- a/internal/handler/customer_wallet_handler.go +++ b/internal/handler/customer_wallet_handler.go @@ -68,3 +68,25 @@ func idempotencyKey(c *gin.Context) string { } return strings.TrimSpace(c.GetHeader(legacyIdempotencyKeyHeader)) } + +// TransferRecipient is GET /customer/wallet/transfer/recipient?phone=. +func (h *CustomerWalletHandler) TransferRecipient(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::TransferRecipient") + if !ok { + return + } + util.HandleResponse(c.Writer, c.Request, h.wallets.TransferRecipient(c.Request.Context(), customerID, c.Query("phone")), "CustomerWalletHandler::TransferRecipient") +} + +// Transfer is POST /customer/wallet/transfer. +func (h *CustomerWalletHandler) Transfer(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Transfer") + if !ok { + return + } + var req contract.TransferWalletRequest + if !bindPinRequest(c, &req, "CustomerWalletHandler::Transfer") { + return + } + util.HandleResponse(c.Writer, c.Request, h.wallets.Transfer(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Transfer") +} diff --git a/internal/models/wallet_move.go b/internal/models/wallet_move.go index 5eb0c64..d348cf4 100644 --- a/internal/models/wallet_move.go +++ b/internal/models/wallet_move.go @@ -42,3 +42,32 @@ type WalletExchangeResult struct { // True when this was a retry of an exchange already made; nothing moved again. Replayed bool `json:"replayed"` } + +// WalletTransferRecipient is GET /customer/wallet/transfer/recipient: who a phone +// number belongs to, masked, so the sender can check before confirming (F5). +type WalletTransferRecipient struct { + Name string `json:"name"` + PhoneNumber string `json:"phone_number"` +} + +// WalletTransfer is what a customer asks to send (F5). +type WalletTransfer struct { + // POINT or COIN. + Currency string + Amount int64 + RecipientPhone string +} + +// WalletTransferResult is POST /customer/wallet/transfer. +type WalletTransferResult struct { + GroupID uuid.UUID `json:"group_id"` + Currency string `json:"currency"` + Amount int64 `json:"amount"` + Recipient WalletTransferRecipient `json:"recipient"` + // What the recipient received, split by the expiry it carried over. + Lots []WalletMovedLot `json:"lots"` + // The sender's balance in the currency sent. + Balance int64 `json:"balance"` + // True when this was a retry of a transfer already made; nothing moved again. + Replayed bool `json:"replayed"` +} diff --git a/internal/processor/wallet_exchange_processor_test.go b/internal/processor/wallet_exchange_processor_test.go index 8306751..873dbd1 100644 --- a/internal/processor/wallet_exchange_processor_test.go +++ b/internal/processor/wallet_exchange_processor_test.go @@ -36,6 +36,9 @@ func newWalletMoveEnv(t *testing.T) *walletMoveEnv { }, pins: &movePinFake{good: "482913"}, } + e.customers.ledger = e.repo + // Ledger rows are stamped from now on, so "today" is the day of e.now. + e.repo.clock = e.now return e } @@ -54,6 +57,12 @@ func (e *walletMoveEnv) exchanges() *WalletExchangeProcessor { return p } +func (e *walletMoveEnv) transfers(messenger walletMessenger) *WalletTransferProcessor { + p := NewWalletTransferProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}, messenger) + p.now = func() time.Time { return e.now } + return p +} + func (e *walletMoveEnv) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) { s := *e.settings return &s, nil @@ -86,7 +95,8 @@ func (e *walletMoveEnv) coinBalance(t *testing.T, customerID uuid.UUID) int64 { } type walletMoveRepoFake struct { - byID map[uuid.UUID]*repository.WalletMoveCustomer + byID map[uuid.UUID]*repository.WalletMoveCustomer + ledger *walletRepoFake } func (f *walletMoveRepoFake) GetCustomer(_ context.Context, id uuid.UUID) (*repository.WalletMoveCustomer, error) { @@ -98,6 +108,25 @@ func (f *walletMoveRepoFake) GetCustomer(_ context.Context, id uuid.UUID) (*repo return &copied, nil } +func (f *walletMoveRepoFake) FindCustomerByPhone(ctx context.Context, phone string) (*repository.WalletMoveCustomer, error) { + for id, c := range f.byID { + if c.PhoneNumber != nil && *c.PhoneNumber == phone { + return f.GetCustomer(ctx, id) + } + } + return nil, repository.ErrWalletNotFound +} + +func (f *walletMoveRepoFake) TransferredOutSince(_ context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) { + var total int64 + for _, tx := range f.ledger.transactions { + if tx.CustomerID == customerID && tx.Currency == currency && tx.Type == constants.WalletTxTypeTransferOut && !tx.CreatedAt.Before(since) { + total -= tx.Amount + } + } + return total, nil +} + // movePinFake accepts one PIN and records the actions it was asked to approve. type movePinFake struct { good string diff --git a/internal/processor/wallet_move_db_test.go b/internal/processor/wallet_move_db_test.go index e65c062..a7d3690 100644 --- a/internal/processor/wallet_move_db_test.go +++ b/internal/processor/wallet_move_db_test.go @@ -2,7 +2,9 @@ package processor import ( "context" + "fmt" "os" + "sync" "testing" "time" @@ -19,7 +21,9 @@ import ( ) // fixedOrganizationSettings serves the same organization settings to every caller. -type fixedOrganizationSettings struct{ s models.OrganizationLoyaltySettings } +type fixedOrganizationSettings struct { + s models.OrganizationLoyaltySettings +} func (f fixedOrganizationSettings) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) { s := f.s @@ -92,3 +96,62 @@ func TestWalletExchange_AgainstPostgres(t *testing.T) { require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE group_id = ?`, res.GroupID).Scan(&rows).Error) assert.Equal(t, int64(2), rows) } + +// Transfers in both directions at once must not deadlock: both lock the two wallets +// in customer_id order. Every one of them lands, and the totals still reconcile. +func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) { + db, _, a, b := walletMoveDB(t) + wallet := NewWalletProcessor(repository.NewWalletRepository(db)) + txm := repository.NewTxManager(db) + moves := repository.NewWalletMoveRepository(db) + settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{ + Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1}, + }} + p := NewWalletTransferProcessor(moves, settings, repository.NewWalletQueryRepository(db), &movePinFake{good: "482913"}, wallet, txm, nil) + + expiry := time.Now().Add(24 * time.Hour).Truncate(time.Second) + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + if _, err := wallet.Credit(ctx, earn(a, 100, &expiry)); err != nil { + return err + } + _, err := wallet.Credit(ctx, earn(b, 100, nil)) + return err + })) + phone := func(id uuid.UUID) string { return "08" + id.String()[:10] } + + const rounds = 10 + errs := make(chan error, 2*rounds) + var wg sync.WaitGroup + for i := 0; i < rounds; i++ { + for _, pair := range [][2]uuid.UUID{{a, b}, {b, a}} { + wg.Add(1) + go func(from, to uuid.UUID, i int) { + defer wg.Done() + _, err := p.Transfer(context.Background(), from, sendPoints(1, phone(to)), "482913", fmt.Sprintf("race-%d", i), models.CustomerPinRequestInfo{}) + errs <- err + }(pair[0], pair[1], i) + } + } + wg.Wait() + close(errs) + for err := range errs { + assert.NoError(t, err) + } + + var balances []int64 + require.NoError(t, db.Raw(`SELECT point_balance FROM customer_wallets WHERE customer_id IN ? ORDER BY point_balance`, []uuid.UUID{a, b}).Scan(&balances).Error) + assert.Equal(t, []int64{100, 100}, balances) + + // B's lots that came from A keep A's expiry to the second. + var mismatched int64 + require.NoError(t, db.Raw(` + SELECT COUNT(*) FROM wallet_lots l JOIN wallet_lots o ON o.id = l.origin_lot_id + WHERE l.customer_id = ? AND o.customer_id = ? AND l.expires_at IS DISTINCT FROM o.expires_at`, b, a).Scan(&mismatched).Error) + assert.Zero(t, mismatched) + + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + sent, err := moves.TransferredOutSince(ctx, a, constants.WalletCurrencyPoint, startOfWalletDay(time.Now())) + assert.Equal(t, int64(rounds), sent) + return err + })) +} diff --git a/internal/processor/wallet_transfer_processor.go b/internal/processor/wallet_transfer_processor.go new file mode 100644 index 0000000..b13c846 --- /dev/null +++ b/internal/processor/wallet_transfer_processor.go @@ -0,0 +1,292 @@ +package processor + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// ErrWalletRecipientNotFound means no customer of the sender's organization has the +// phone number. A customer of another organization is reported the same way, so the +// check does not reveal who uses the app elsewhere. +var ErrWalletRecipientNotFound = errors.New("no customer of this organization has that phone number") + +// walletMessenger tells a customer something happened to their wallet. There is no +// push channel to customers yet, so the app sends it by WhatsApp. +type walletMessenger interface { + SendWhatsAppMessage(phoneNumber, message string) error +} + +// WalletTransferProcessor sends EnakPoint or EnakCoin from one customer to another in +// the same organization (docs/prd-point-coin.md F5). +type WalletTransferProcessor struct { + customers repository.WalletMoveRepository + settings organizationSettingsReader + spendable spendableReader + pins pinVerifier + wallet *WalletProcessor + tx TxRunner + messenger walletMessenger + now func() time.Time +} + +func NewWalletTransferProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner, messenger walletMessenger) *WalletTransferProcessor { + return &WalletTransferProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, messenger: messenger, now: time.Now} +} + +// Recipient is GET /customer/wallet/transfer/recipient: the masked name and number +// of the customer a phone number belongs to, if the sender may send to them. +func (p *WalletTransferProcessor) Recipient(ctx context.Context, senderID uuid.UUID, phoneNumber string) (*models.WalletTransferRecipient, error) { + sender, err := p.customers.GetCustomer(ctx, senderID) + if err != nil { + return nil, err + } + recipient, err := p.recipient(ctx, sender, phoneNumber) + if err != nil { + return nil, err + } + return maskedRecipient(recipient), nil +} + +// Transfer sends in.Amount of in.Currency to the customer with in.RecipientPhone, +// approved by the sender's PIN (K8), and tells the recipient. +// +// Both wallets are locked in customer_id order, so two transfers in opposite +// directions cannot deadlock. TRANSFER_OUT takes from the sender's lots in K9 order, +// and TRANSFER_IN gives the recipient lots with exactly the same expiries, pointing +// back at the sender's lots, so sending a balance back and forth cannot extend it. +// The two rows share a group and name each other's customer. +// +// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns +// the first transfer without moving anything again or counting against the limits. +func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UUID, in models.WalletTransfer, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.WalletTransferResult, error) { + reject := func(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrWalletMoveRejected, fmt.Sprintf(format, args...)) + } + key, err := walletMoveKey(idempotencyKey) + if err != nil { + return nil, err + } + currency := strings.ToUpper(strings.TrimSpace(in.Currency)) + if !constants.IsValidWalletCurrency(currency) { + return nil, reject("currency must be POINT or COIN") + } + if in.Amount <= 0 { + return nil, reject("the amount must be positive") + } + sender, err := p.customers.GetCustomer(ctx, senderID) + if err != nil { + return nil, err + } + if !sender.IsActive { + return nil, reject("the customer is not active") + } + settings, err := p.settings.Organization(ctx, sender.OrganizationID) + if err != nil { + return nil, err + } + limits := settings.Transfer + switch { + case !limits.Enabled: + return nil, reject("transfers are turned off") + case in.Amount < limits.MinAmount: + return nil, reject("at least %d can be sent at a time", limits.MinAmount) + case limits.MaxPerTransaction != nil && in.Amount > *limits.MaxPerTransaction: + return nil, reject("at most %d can be sent at a time", *limits.MaxPerTransaction) + } + recipient, err := p.recipient(ctx, sender, in.RecipientPhone) + if err != nil { + return nil, err + } + // Everything the request alone can get wrong is refused above, before the PIN, so + // it costs no attempt. The PIN also refuses a transfer held after a PIN reset. + if err := p.pins.VerifyPin(ctx, senderID, pin, PinActionTransfer, info); err != nil { + return nil, err + } + + to, from := maskedRecipient(recipient), maskedRecipient(sender) + outKey := fmt.Sprintf("transfer:%s:%s:out", senderID, key) + inKey := fmt.Sprintf("transfer:%s:%s:in", senderID, key) + result := &models.WalletTransferResult{Currency: currency, Amount: in.Amount, Recipient: *to} + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if err := p.wallet.LockWallets(ctx, senderID, recipient.ID); err != nil { + return err + } + groupID, outID, inID := uuid.New(), uuid.New(), uuid.New() + previous, err := p.wallet.FindTransaction(ctx, outKey) + if err != nil { + return err + } + if previous != nil { + // A retry: it replays below, so it must not count against the daily limit + // it is already part of. + if previous.CounterpartyCustomerID == nil || *previous.CounterpartyCustomerID != recipient.ID || previous.GroupID == nil { + return ErrWalletIdempotencyConflict + } + outID, inID, groupID = previous.ID, previous.ReferenceID, *previous.GroupID + } else if limits.DailyLimit != nil { + sent, err := p.customers.TransferredOutSince(ctx, senderID, currency, startOfWalletDay(p.now())) + if err != nil { + return err + } + if sent+in.Amount > *limits.DailyLimit { + return reject("at most %d can be sent per day; %d is left today", *limits.DailyLimit, max(*limits.DailyLimit-sent, 0)) + } + } + + out, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ + TransactionID: outID, + CustomerID: senderID, + Currency: currency, + Type: constants.WalletTxTypeTransferOut, + Amount: in.Amount, + ReferenceType: constants.WalletRefTypeWalletTx, + ReferenceID: inID, + GroupID: &groupID, + CounterpartyCustomerID: &recipient.ID, + Description: truncateRunes(fmt.Sprintf("Transfer ke %s (%s)", to.Name, to.PhoneNumber), walletDescriptionLimit), + IdempotencyKey: outKey, + }}) + if errors.Is(err, repository.ErrWalletInsufficientBalance) { + return reject("not enough %s", walletCurrencyName(currency)) + } + if err != nil { + return err + } + received, err := p.wallet.Credit(ctx, WalletCreditInput{ + WalletEntry: WalletEntry{ + TransactionID: inID, + CustomerID: recipient.ID, + Currency: currency, + Type: constants.WalletTxTypeTransferIn, + Amount: in.Amount, + ReferenceType: constants.WalletRefTypeWalletTx, + ReferenceID: outID, + GroupID: &groupID, + CounterpartyCustomerID: &senderID, + Description: truncateRunes(fmt.Sprintf("Transfer dari %s (%s)", from.Name, from.PhoneNumber), walletDescriptionLimit), + IdempotencyKey: inKey, + }, + Lots: out.CarryOver(), + }) + if err != nil { + return err + } + result.GroupID = groupID + result.Lots = movedLots(received.Lots) + result.Replayed = out.Replayed + return nil + }) + if err != nil { + return nil, err + } + + if !result.Replayed { + p.tellRecipient(recipient, from, currency, in.Amount) + } + balances, err := p.spendable.SpendableBalances(ctx, senderID, p.now()) + if err != nil { + return nil, err + } + result.Balance = balances[currency] + return result, nil +} + +// recipient finds who a phone number belongs to and checks the sender may send to +// 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 == "" { + return nil, fmt.Errorf("%w: the recipient's phone number is required", ErrWalletMoveRejected) + } + recipient, err := p.customers.FindCustomerByPhone(ctx, phoneNumber) + if errors.Is(err, repository.ErrWalletNotFound) { + return nil, ErrWalletRecipientNotFound + } + if err != nil { + return nil, err + } + switch { + case recipient.OrganizationID != sender.OrganizationID: + return nil, ErrWalletRecipientNotFound + case recipient.ID == sender.ID: + return nil, fmt.Errorf("%w: you cannot send to yourself", ErrWalletMoveRejected) + case recipient.IsDefault || !recipient.IsActive: + return nil, fmt.Errorf("%w: this customer cannot receive transfers", ErrWalletMoveRejected) + } + return recipient, nil +} + +// tellRecipient is best effort: the transfer has already happened, so a failure to +// send the message is only logged. +func (p *WalletTransferProcessor) tellRecipient(recipient *repository.WalletMoveCustomer, sender *models.WalletTransferRecipient, currency string, amount int64) { + if p.messenger == nil || recipient.PhoneNumber == nil { + return + } + message := fmt.Sprintf("Kamu menerima %d %s dari %s (%s). Cek riwayatnya di aplikasi.", + amount, walletCurrencyName(currency), sender.Name, sender.PhoneNumber) + if err := p.messenger.SendWhatsAppMessage(*recipient.PhoneNumber, message); err != nil { + logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about a transfer", recipient.ID), err) + } +} + +func maskedRecipient(c *repository.WalletMoveCustomer) *models.WalletTransferRecipient { + phone := "" + if c.PhoneNumber != nil { + phone = maskPhoneNumber(*c.PhoneNumber) + } + return &models.WalletTransferRecipient{Name: maskName(c.Name), PhoneNumber: phone} +} + +// maskName keeps the first two letters of each word, "Budi Santoso" → "Bu*** Sa***", +// and one letter of a word that short, so the sender can recognise the recipient +// without the app revealing their name (F5, §8.1). +func maskName(name string) string { + words := strings.Fields(name) + if len(words) == 0 { + return "***" + } + for i, w := range words { + keep := 2 + if utf8.RuneCountInString(w) <= 2 { + keep = 1 + } + words[i] = string([]rune(w)[:keep]) + "***" + } + return strings.Join(words, " ") +} + +// maskPhoneNumber keeps the first two and the last four digits: +// "081234561234" → "08**-****-1234". +func maskPhoneNumber(phone string) string { + runes := []rune(strings.TrimSpace(phone)) + if len(runes) < 8 { + return "****" + } + return string(runes[:2]) + "**-****-" + string(runes[len(runes)-4:]) +} + +func walletCurrencyName(currency string) string { + if currency == constants.WalletCurrencyCoin { + return "EnakCoin" + } + return "EnakPoint" +} + +// startOfWalletDay is midnight of t's day in the customer's time zone, where the +// daily transfer limit starts over. +func startOfWalletDay(t time.Time) time.Time { + local := t.In(walletDisplayLocation) + return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, walletDisplayLocation) +} diff --git a/internal/processor/wallet_transfer_processor_test.go b/internal/processor/wallet_transfer_processor_test.go new file mode 100644 index 0000000..1aa14a9 --- /dev/null +++ b/internal/processor/wallet_transfer_processor_test.go @@ -0,0 +1,225 @@ +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")) +} diff --git a/internal/repository/wallet_move_repository.go b/internal/repository/wallet_move_repository.go index 08f983d..d8cbb70 100644 --- a/internal/repository/wallet_move_repository.go +++ b/internal/repository/wallet_move_repository.go @@ -4,9 +4,12 @@ import ( "context" "errors" "fmt" + "time" "github.com/google/uuid" "gorm.io/gorm" + + "apskel-pos-be/internal/constants" ) // WalletMoveCustomer is a customer on either side of an exchange or a transfer. @@ -25,6 +28,12 @@ type WalletMoveCustomer struct { type WalletMoveRepository interface { // GetCustomer returns ErrWalletNotFound when the customer does not exist. GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error) + // FindCustomerByPhone returns ErrWalletNotFound when no customer has the number. + // Phone numbers are unique across organizations, so there is at most one. + FindCustomerByPhone(ctx context.Context, phoneNumber string) (*WalletMoveCustomer, error) + // TransferredOutSince sums what a customer has sent in one currency since a time. + // Call it under the sender's wallet lock, so transfers at the same time count. + TransferredOutSince(ctx context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) } type walletMoveRepository struct { @@ -39,6 +48,23 @@ func (r *walletMoveRepository) GetCustomer(ctx context.Context, customerID uuid. return r.customer(ctx, "id = ?", customerID) } +func (r *walletMoveRepository) FindCustomerByPhone(ctx context.Context, phoneNumber string) (*WalletMoveCustomer, error) { + return r.customer(ctx, "phone_number = ?", phoneNumber) +} + +func (r *walletMoveRepository) TransferredOutSince(ctx context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) { + var total int64 + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("wallet_transactions"). + Select("COALESCE(SUM(-amount), 0)"). + Where("customer_id = ? AND currency = ? AND type = ? AND created_at >= ?", customerID, currency, constants.WalletTxTypeTransferOut, since). + Scan(&total).Error + if err != nil { + return 0, fmt.Errorf("failed to sum transfers: %w", err) + } + return total, nil +} + func (r *walletMoveRepository) customer(ctx context.Context, where string, arg interface{}) (*WalletMoveCustomer, error) { var c WalletMoveCustomer err := DBFromContext(ctx, r.db).WithContext(ctx). diff --git a/internal/router/router.go b/internal/router/router.go index 7052040..1bec687 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -173,6 +173,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode) customer.GET("/wallet/exchange/preview", r.customerWalletHandler.PreviewExchange) customer.POST("/wallet/exchange", r.customerWalletHandler.Exchange) + customer.GET("/wallet/transfer/recipient", r.customerWalletHandler.TransferRecipient) + customer.POST("/wallet/transfer", r.customerWalletHandler.Transfer) customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints) // PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11) customer.GET("/pin/status", r.customerPinHandler.Status) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index a0b5daa..5887bae 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -38,6 +38,8 @@ func TestAllRoutesRegister(t *testing.T) { "POST /api/v1/customer/wallet/payment-code", "GET /api/v1/customer/wallet/exchange/preview", "POST /api/v1/customer/wallet/exchange", + "GET /api/v1/customer/wallet/transfer/recipient", + "POST /api/v1/customer/wallet/transfer", "GET /api/v1/orders/:id/point-payment/preview", "POST /api/v1/customer/orders/:id/pay-with-points", "GET /api/v1/customer/pin/status", diff --git a/internal/service/customer_wallet_service.go b/internal/service/customer_wallet_service.go index fed1450..897a32e 100644 --- a/internal/service/customer_wallet_service.go +++ b/internal/service/customer_wallet_service.go @@ -14,18 +14,42 @@ import ( ) // CustomerWalletService moves balance on the customer's own request: exchanging -// EnakCoin into EnakPoint (docs/prd-point-coin.md F4). +// EnakCoin into EnakPoint and sending either to another customer +// (docs/prd-point-coin.md F4, F5). type CustomerWalletService interface { PreviewExchange(ctx context.Context, customerID uuid.UUID, coins int64) *contract.Response Exchange(ctx context.Context, customerID uuid.UUID, req *contract.ExchangeCoinsRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response + TransferRecipient(ctx context.Context, customerID uuid.UUID, phoneNumber string) *contract.Response + Transfer(ctx context.Context, customerID uuid.UUID, req *contract.TransferWalletRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response } type CustomerWalletServiceImpl struct { exchanges *processor.WalletExchangeProcessor + transfers *processor.WalletTransferProcessor } -func NewCustomerWalletService(exchanges *processor.WalletExchangeProcessor) *CustomerWalletServiceImpl { - return &CustomerWalletServiceImpl{exchanges: exchanges} +func NewCustomerWalletService(exchanges *processor.WalletExchangeProcessor, transfers *processor.WalletTransferProcessor) *CustomerWalletServiceImpl { + return &CustomerWalletServiceImpl{exchanges: exchanges, transfers: transfers} +} + +func (s *CustomerWalletServiceImpl) TransferRecipient(ctx context.Context, customerID uuid.UUID, phoneNumber string) *contract.Response { + recipient, err := s.transfers.Recipient(ctx, customerID, phoneNumber) + if err != nil { + return walletMoveErrorResponse(err) + } + return contract.BuildSuccessResponse(recipient) +} + +func (s *CustomerWalletServiceImpl) Transfer(ctx context.Context, customerID uuid.UUID, req *contract.TransferWalletRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response { + result, err := s.transfers.Transfer(ctx, customerID, models.WalletTransfer{ + Currency: req.Currency, + Amount: req.Amount, + RecipientPhone: req.RecipientPhone, + }, req.Pin, idempotencyKey, info) + if err != nil { + return walletMoveErrorResponse(err) + } + return contract.BuildSuccessResponse(result) } func (s *CustomerWalletServiceImpl) PreviewExchange(ctx context.Context, customerID uuid.UUID, coins int64) *contract.Response { @@ -53,7 +77,8 @@ func walletMoveErrorResponse(err error) *contract.Response { } code := constants.InternalServerErrorCode switch { - case errors.Is(err, repository.ErrWalletNotFound): + case errors.Is(err, repository.ErrWalletNotFound), + errors.Is(err, processor.ErrWalletRecipientNotFound): code = constants.NotFoundErrorCode case errors.Is(err, processor.ErrWalletMoveRejected), errors.Is(err, processor.ErrWalletIdempotencyConflict),