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) }