From 694d65b6d89b9373ffbb621d7f9b454e59bff451 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 12:14:32 +0700 Subject: [PATCH] feat(loyalty): send EnakPoint and EnakCoin to another customer Adds GET /customer/wallet/transfer/recipient?phone= and POST /customer/wallet/transfer (docs/prd-point-coin.md F5, Q4, Q16, PC-402). The recipient is found by phone number and must be an active customer of the same organization, not the walk-in customer and not the sender. A number of another organization answers 404 like an unknown one, so the check does not reveal who uses the app elsewhere. The recipient check returns the name and number masked ("Bu*** Sa***", "08**-****-1234"). The organization's transfer settings apply: transfers turned off, the minimum, the maximum per transaction and the daily limit per currency, which starts over at midnight WIB. Everything the request alone can get wrong is refused before the PIN, so it costs no attempt; the PIN then refuses a transfer held for 24 hours after a PIN reset. Both wallets are locked in customer_id order, so transfers in opposite directions cannot deadlock, and the daily limit is summed under the lock. 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. The rows share a group, reference each other and name the other customer; descriptions carry only the masked name. The Idempotency-Key header is required. A retry is recognised under the lock before the daily limit, so it replays instead of counting twice; the same key towards another recipient is refused. The recipient is told by WhatsApp after the commit, as PIN locks are: NotificationService only reaches staff devices, there is no push channel to customers yet. A failure to send is logged, never undoes the transfer. Transfers must not be released before note N3 (legal) is closed. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 6 +- internal/contract/wallet_contract.go | 10 + internal/handler/customer_wallet_handler.go | 22 ++ internal/models/wallet_move.go | 29 ++ .../wallet_exchange_processor_test.go | 31 +- internal/processor/wallet_move_db_test.go | 65 +++- .../processor/wallet_transfer_processor.go | 292 ++++++++++++++++++ .../wallet_transfer_processor_test.go | 225 ++++++++++++++ internal/repository/wallet_move_repository.go | 26 ++ internal/router/router.go | 2 + internal/router/router_test.go | 2 + internal/service/customer_wallet_service.go | 33 +- 12 files changed, 736 insertions(+), 7 deletions(-) create mode 100644 internal/processor/wallet_transfer_processor.go create mode 100644 internal/processor/wallet_transfer_processor_test.go 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),