2026-09-30 15:31:44 +07:00
|
|
|
package processor
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"strconv"
|
|
|
|
|
"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"
|
2026-10-09 22:58:24 +07:00
|
|
|
"apskel-pos-be/internal/util"
|
2026-09-30 15:31:44 +07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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")
|
|
|
|
|
|
|
|
|
|
// customerNotifier pushes a notification to a customer's app through FCM.
|
|
|
|
|
// CustomerDeviceProcessor is one.
|
|
|
|
|
type customerNotifier interface {
|
|
|
|
|
Notify(ctx context.Context, customerID uuid.UUID, title, body string, data map[string]string) error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NotificationTypeWalletTransferIn is the data type of the push a transfer recipient
|
|
|
|
|
// gets, so the app can open the transaction.
|
|
|
|
|
const NotificationTypeWalletTransferIn = "WALLET_TRANSFER_IN"
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
notifier customerNotifier
|
|
|
|
|
now func() time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewWalletTransferProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletTransferProcessor {
|
|
|
|
|
return &WalletTransferProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, notifier: notifier, 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}
|
|
|
|
|
var receivedID uuid.UUID
|
|
|
|
|
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
|
|
|
|
|
receivedID = received.Transaction.ID
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !result.Replayed {
|
|
|
|
|
p.tellRecipient(ctx, recipient.ID, from, currency, in.Amount, receivedID, result.GroupID)
|
|
|
|
|
}
|
|
|
|
|
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) {
|
2026-10-09 22:58:24 +07:00
|
|
|
if strings.TrimSpace(phoneNumber) == "" {
|
2026-09-30 15:31:44 +07:00
|
|
|
return nil, fmt.Errorf("%w: the recipient's phone number is required", ErrWalletMoveRejected)
|
|
|
|
|
}
|
2026-10-09 22:58:24 +07:00
|
|
|
phoneNumber, err := util.NormalizePhoneNumber(phoneNumber)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: the recipient's phone number is not valid", ErrWalletMoveRejected)
|
|
|
|
|
}
|
2026-09-30 15:31:44 +07:00
|
|
|
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 pushes the transfer to the recipient's app (F5). It is best effort:
|
|
|
|
|
// the transfer has already happened, so a failure to send is only logged.
|
|
|
|
|
func (p *WalletTransferProcessor) tellRecipient(ctx context.Context, recipientID uuid.UUID, sender *models.WalletTransferRecipient, currency string, amount int64, transactionID, groupID uuid.UUID) {
|
|
|
|
|
if p.notifier == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
name := walletCurrencyName(currency)
|
|
|
|
|
title := name + " masuk"
|
|
|
|
|
body := fmt.Sprintf("Kamu menerima %d %s dari %s (%s).", amount, name, sender.Name, sender.PhoneNumber)
|
|
|
|
|
data := map[string]string{
|
|
|
|
|
"type": NotificationTypeWalletTransferIn,
|
|
|
|
|
"transaction_id": transactionID.String(),
|
|
|
|
|
"group_id": groupID.String(),
|
|
|
|
|
"currency": currency,
|
|
|
|
|
"amount": strconv.FormatInt(amount, 10),
|
|
|
|
|
}
|
|
|
|
|
if err := p.notifier.Notify(ctx, recipientID, title, body, data); err != nil {
|
|
|
|
|
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about a transfer", recipientID), 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, " ")
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-09 22:58:24 +07:00
|
|
|
// maskPhoneNumber keeps the first two and the last four digits of the number as
|
|
|
|
|
// customers write it, with 0 for 62: "6281234561234" → "08**-****-1234".
|
2026-09-30 15:31:44 +07:00
|
|
|
func maskPhoneNumber(phone string) string {
|
2026-10-09 22:58:24 +07:00
|
|
|
phone = strings.TrimSpace(phone)
|
|
|
|
|
if strings.HasPrefix(phone, "62") {
|
|
|
|
|
phone = "0" + phone[2:]
|
|
|
|
|
}
|
|
|
|
|
runes := []rune(phone)
|
2026-09-30 15:31:44 +07:00
|
|
|
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)
|
|
|
|
|
}
|