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 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 12:14:32 +07:00
co-authored by Claude Opus 5.5
parent ab3425070b
commit 694d65b6d8
12 changed files with 736 additions and 7 deletions
@@ -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)
}