Files
apskel-pos-backend/internal/processor/wallet_exchange_processor.go
T
efrilmandClaude Opus 5.5 4d63673a25 feat(loyalty): give new balances their expiry
Every lot now gets its expiry when it is created (docs/prd-point-coin.md
F12, PC-502), where it used to never expire until note N4 was settled:

- EARN and an ADJUSTMENT that adds: ComputeExpiry of the organization's
  settings for that currency, from the moment received.
- EXCHANGE_IN: the sooner of the EnakCoin lot's expiry and when EnakPoint
  received now expire (F4).
- PAYMENT_REFUND: the expiry of the lot the EnakPoint came from, but at
  least seven days from the refund (N4, decided). A lot that never expired
  stays so.
- TRANSFER_IN: unchanged, exactly the sender's expiry.

Turning expiry on for a currency for the first time dates every lot of the
organization that still holds something and has no expiry, MIGRATION lots
included, in the same transaction as the setting: a full period from now
when ROLLING, the second fixed date on or after today when FIXED_DATE, so
no customer loses a balance soon after the rule is announced (N4,
decided). Turning it off leaves dated lots as they are. PUT
/marketing/loyalty-settings reports these as expiry_activations (currency,
lots, amount, expires_at); a dry run counts them without dating anything.

The earning processor now also reads the organization settings, and the
wallet admin processor takes the settings reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:29:25 +07:00

290 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package processor
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// ErrWalletMoveRejected wraps every reason an exchange or a transfer is refused on
// the customer's side: the amount, the limits, the recipient or the balance. The
// message says which.
var ErrWalletMoveRejected = errors.New("wallet move refused")
// walletMoveKeyLimit keeps a client's Idempotency-Key short enough to fit, with the
// prefix that scopes it to the customer, in wallet_transactions.idempotency_key.
const walletMoveKeyLimit = 50
type organizationSettingsReader interface {
Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error)
}
// WalletExchangeProcessor exchanges EnakCoin into EnakPoint (docs/prd-point-coin.md
// F4, K3). It is one way only; nothing turns EnakPoint back into EnakCoin.
type WalletExchangeProcessor struct {
customers repository.WalletMoveRepository
settings organizationSettingsReader
spendable spendableReader
pins pinVerifier
wallet *WalletProcessor
tx TxRunner
now func() time.Time
}
func NewWalletExchangeProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner) *WalletExchangeProcessor {
return &WalletExchangeProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, now: time.Now}
}
// Preview is GET /customer/wallet/exchange/preview: the organization's rate and what
// exchanging coins would give, so the app can show it before asking for the PIN.
func (p *WalletExchangeProcessor) Preview(ctx context.Context, customerID uuid.UUID, coins int64) (*models.WalletExchangePreview, error) {
customer, err := p.customers.GetCustomer(ctx, customerID)
if err != nil {
return nil, err
}
settings, err := p.settings.Organization(ctx, customer.OrganizationID)
if err != nil {
return nil, err
}
balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now())
if err != nil {
return nil, err
}
rate := settings.Exchange
preview := &models.WalletExchangePreview{
CoinAmount: rate.CoinAmount,
PointAmount: rate.PointAmount,
CoinBalance: balances[constants.WalletCurrencyCoin],
Coins: coins,
}
if reason := exchangeProblem(customer, rate, coins); reason != "" {
preview.Reason = reason
return preview, nil
}
preview.Points = exchangePoints(coins, rate)
if coins > preview.CoinBalance {
preview.Reason = "not enough EnakCoin"
return preview, nil
}
preview.Valid = true
return preview, nil
}
// Exchange takes coins EnakCoin and gives the EnakPoint they are worth at the
// organization's rate, in one transaction. The two ledger rows share a group and
// point at each other, and both freeze the rate. Each EnakPoint lot keeps the expiry
// of the EnakCoin lot it came from, so exchanging cannot extend a balance's life
// (K9). The PIN approves it (K8).
//
// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns
// the first exchange, at the rate it was made, without moving anything again.
func (p *WalletExchangeProcessor) Exchange(ctx context.Context, customerID uuid.UUID, coins int64, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.WalletExchangeResult, error) {
key, err := walletMoveKey(idempotencyKey)
if err != nil {
return nil, err
}
customer, err := p.customers.GetCustomer(ctx, customerID)
if err != nil {
return nil, err
}
settings, err := p.settings.Organization(ctx, customer.OrganizationID)
if err != nil {
return nil, err
}
// Refuse a malformed request before the PIN is checked, so a typo in the amount
// costs the customer no PIN attempt.
if reason := exchangeProblem(customer, settings.Exchange, coins); reason != "" {
return nil, fmt.Errorf("%w: %s", ErrWalletMoveRejected, reason)
}
if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionExchange, info); err != nil {
return nil, err
}
outKey := fmt.Sprintf("exchange:%s:%s:out", customerID, key)
inKey := fmt.Sprintf("exchange:%s:%s:in", customerID, key)
result := &models.WalletExchangeResult{Coins: coins}
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if err := p.wallet.LockWallet(ctx, customerID); err != nil {
return err
}
rate := settings.Exchange
groupID, outID, inID := uuid.New(), uuid.New(), uuid.New()
previous, err := p.wallet.FindTransaction(ctx, outKey)
if err != nil {
return err
}
if previous != nil && previous.GroupID != nil {
// A retry: repeat it with the ids and the rate the first attempt froze, so
// both rows replay even if the rate has changed since.
outID, inID, groupID = previous.ID, previous.ReferenceID, *previous.GroupID
rate = frozenExchangeRate(previous.Metadata, rate)
}
points := exchangePoints(coins, rate)
metadata := entities.Metadata{
"coins": coins,
"points": points,
"coin_amount": rate.CoinAmount,
"point_amount": rate.PointAmount,
}
out, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
TransactionID: outID,
CustomerID: customerID,
Currency: constants.WalletCurrencyCoin,
Type: constants.WalletTxTypeExchangeOut,
Amount: coins,
ReferenceType: constants.WalletRefTypeWalletTx,
ReferenceID: inID,
GroupID: &groupID,
Description: fmt.Sprintf("Tukar %d EnakCoin ke EnakPoint", coins),
Metadata: metadata,
IdempotencyKey: outKey,
}})
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
return fmt.Errorf("%w: not enough EnakCoin", ErrWalletMoveRejected)
}
if err != nil {
return err
}
in, err := p.wallet.Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{
TransactionID: inID,
CustomerID: customerID,
Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeExchangeIn,
Amount: points,
ReferenceType: constants.WalletRefTypeWalletTx,
ReferenceID: outID,
GroupID: &groupID,
Description: fmt.Sprintf("Dari tukar %d EnakCoin", coins),
Metadata: metadata,
IdempotencyKey: inKey,
},
Lots: exchangeLots(out.Allocations, rate, ComputeExpiry(settings.PointExpiry, p.now())),
})
if err != nil {
return err
}
result.GroupID = groupID
result.Points = points
result.CoinAmount = rate.CoinAmount
result.PointAmount = rate.PointAmount
result.Lots = movedLots(in.Lots)
result.Replayed = out.Replayed
return nil
})
if err != nil {
return nil, err
}
balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now())
if err != nil {
return nil, err
}
result.CoinBalance = balances[constants.WalletCurrencyCoin]
result.PointBalance = balances[constants.WalletCurrencyPoint]
return result, nil
}
// exchangeProblem says why coins cannot be exchanged, or "" when they can as far as
// the request goes. The balance is checked under the wallet lock.
func exchangeProblem(customer *repository.WalletMoveCustomer, rate models.LoyaltyExchangeSettings, coins int64) string {
switch {
case !customer.IsActive:
return "the customer is not active"
case coins <= 0:
return "the number of EnakCoin must be positive"
case rate.CoinAmount <= 0 || rate.PointAmount <= 0:
return "exchange is not available"
case coins%rate.CoinAmount != 0:
// Otherwise part of the EnakCoin would be lost to rounding (F4).
return fmt.Sprintf("EnakCoin are exchanged in multiples of %d", rate.CoinAmount)
}
return ""
}
// exchangePoints is (coins / coin_amount) × point_amount, for coins that are a
// multiple of coin_amount.
func exchangePoints(coins int64, rate models.LoyaltyExchangeSettings) int64 {
return coins / rate.CoinAmount * rate.PointAmount
}
// exchangeLots splits the EnakPoint of an exchange over the EnakCoin lots it took,
// so each part keeps the expiry of its lot and points back at it (K9). The share of
// a lot is the difference of floor(coins so far × point_amount / coin_amount) before
// and after it, which adds up exactly because the total is a multiple of
// coin_amount. A lot too small to earn a whole EnakPoint on its own gives none.
//
// Each part expires at the sooner of its EnakCoin lot's expiry and pointExpiry, when an
// EnakPoint received now would expire (F4); nil means never.
func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings, pointExpiry *time.Time) []WalletLotInput {
var lots []WalletLotInput
var coinsSoFar int64
for _, a := range allocations {
before := coinsSoFar * rate.PointAmount / rate.CoinAmount
coinsSoFar += a.Amount
points := coinsSoFar*rate.PointAmount/rate.CoinAmount - before
if points == 0 {
continue
}
lotID := a.LotID
lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: EarlierExpiry(a.ExpiresAt, pointExpiry), OriginLotID: &lotID})
}
return lots
}
// frozenExchangeRate reads the rate an exchange was made at from its ledger row.
func frozenExchangeRate(metadata entities.Metadata, fallback models.LoyaltyExchangeSettings) models.LoyaltyExchangeSettings {
coinAmount, ok1 := metadataInt(metadata, "coin_amount")
pointAmount, ok2 := metadataInt(metadata, "point_amount")
if !ok1 || !ok2 || coinAmount <= 0 || pointAmount <= 0 {
return fallback
}
return models.LoyaltyExchangeSettings{CoinAmount: coinAmount, PointAmount: pointAmount}
}
// metadataInt reads a whole number from metadata that may have been through JSONB,
// which gives numbers back as float64.
func metadataInt(metadata entities.Metadata, key string) (int64, bool) {
switch v := metadata[key].(type) {
case float64:
return int64(v), true
case int64:
return v, true
case int:
return int64(v), true
}
return 0, false
}
func movedLots(lots []entities.WalletLot) []models.WalletMovedLot {
out := make([]models.WalletMovedLot, 0, len(lots))
for _, lot := range lots {
out = append(out, models.WalletMovedLot{Amount: lot.OriginalAmount, ExpiresAt: lot.ExpiresAt})
}
return out
}
// walletMoveKey checks the client's Idempotency-Key, which exchanges and transfers
// require (F4, F5).
func walletMoveKey(key string) (string, error) {
key = strings.TrimSpace(key)
if key == "" {
return "", fmt.Errorf("%w: the Idempotency-Key header is required", ErrWalletMoveRejected)
}
if len(key) > walletMoveKeyLimit {
return "", fmt.Errorf("%w: the Idempotency-Key header must be at most %d characters", ErrWalletMoveRejected, walletMoveKeyLimit)
}
return key, nil
}