A customer's phone number (customers.phone_number, the one they log in with) has one stored form, 62 followed by the number without its 0: 6281234561234. util.NormalizePhoneNumber rewrites 0812…, +62 812…, 62812…, 812… and +62 0812…, with spaces, dashes, dots or parentheses, to it, and refuses anything that is not an Indonesian mobile number (628 and 7 to 11 more digits). It is applied wherever a customer types their number: check-phone, register, login and resend-OTP (the validator rewrites the request), and the transfer recipient. Before, the number was matched as typed and a leading 0 was refused, so the same customer written another way was not found. The masked recipient stays 08**-****-1234 as customers write numbers. Migration 000116 rewrites the numbers already stored in customers and otp_sessions. When several become the same number, the customer that already has it keeps it, else a registered one, else the oldest; the others, and numbers that are not Indonesian mobile numbers, are left as they are and cannot log in until fixed by hand (the query to find them is in the migration). Down does nothing. The migration was run, twice, against Postgres with a reduced customers and otp_sessions schema and sample numbers; not against the real schema. The Postgres tests were not run. customers.phone (the POS contact number) is not touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
316 lines
12 KiB
Go
316 lines
12 KiB
Go
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"
|
|
"apskel-pos-be/internal/util"
|
|
)
|
|
|
|
// 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) {
|
|
if strings.TrimSpace(phoneNumber) == "" {
|
|
return nil, fmt.Errorf("%w: the recipient's phone number is required", ErrWalletMoveRejected)
|
|
}
|
|
phoneNumber, err := util.NormalizePhoneNumber(phoneNumber)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: the recipient's phone number is not valid", 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 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, " ")
|
|
}
|
|
|
|
// maskPhoneNumber keeps the first two and the last four digits of the number as
|
|
// customers write it, with 0 for 62: "6281234561234" → "08**-****-1234".
|
|
func maskPhoneNumber(phone string) string {
|
|
phone = strings.TrimSpace(phone)
|
|
if strings.HasPrefix(phone, "62") {
|
|
phone = "0" + phone[2:]
|
|
}
|
|
runes := []rune(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)
|
|
}
|