Revert "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts merge commit645da30, returning main tof0ff59f. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
645da3048e
commit
4e24f9bbb0
@@ -1,307 +0,0 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user