The recipient of a transfer now gets a push through FCM instead of a
WhatsApp message (docs/prd-point-coin.md F5).
Customers had nowhere to keep FCM tokens: user_devices only holds staff
devices. Migration 000096 adds customer_devices, and the customer app
registers with PUT /customer/devices { device_id, fcm_token, platform,
app_version } after login and whenever FCM refreshes the token, and
unregisters with DELETE /customer/devices/:device_id on logout. A token
belongs to one customer only: registering it takes it away from whoever
had it on that phone before, so they stop getting this customer's
notifications.
The push goes to every device of the recipient after the commit, titled
"EnakPoint masuk" or "EnakCoin masuk", with type WALLET_TRANSFER_IN, the
TRANSFER_IN transaction id, the group id, the currency and the amount in
its data so the app can open it. A retried transfer sends nothing again. It
stays best effort: no device, FCM not configured or FCM failing is logged
and never undoes the transfer.
The app builds one FCM client and shares it between staff notifications
and customer pushes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
308 lines
12 KiB
Go
308 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"
|
|
)
|
|
|
|
// 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) {
|
|
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 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:
|
|
// "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)
|
|
}
|