feat(loyalty): EnakPoint & EnakCoin #32
+5
-1
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -87,6 +96,7 @@ func (e *walletMoveEnv) coinBalance(t *testing.T, customerID uuid.UUID) int64 {
|
||||
|
||||
type walletMoveRepoFake struct {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user