Files
apskel-pos-backend/internal/processor/wallet_admin_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

224 lines
7.2 KiB
Go

package processor
import (
"context"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// walletDescriptionLimit is the size of wallet_transactions.description.
const walletDescriptionLimit = 255
// WalletAdminProcessor serves the dashboard's view of one customer's wallet and the
// manual adjustments an admin makes to it (docs/prd-point-coin.md F7).
type WalletAdminProcessor struct {
admin repository.WalletAdminRepository
query repository.WalletQueryRepository
wallet *WalletProcessor
settings organizationSettingsReader
tx TxRunner
now func() time.Time
}
func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, settings organizationSettingsReader, tx TxRunner) *WalletAdminProcessor {
return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, settings: settings, tx: tx, now: time.Now}
}
// GetCustomerWallet returns the customer's balances, lots and a page of history.
// A customer outside the organization is reported as not found.
func (p *WalletAdminProcessor) GetCustomerWallet(ctx context.Context, organizationID, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) (*models.AdminCustomerWallet, error) {
filter, page, err := walletTransactionFilter(customerID, query)
if err != nil {
return nil, err
}
customer, err := p.admin.GetCustomer(ctx, organizationID, customerID)
if err != nil {
return nil, err
}
now := p.now()
result := &models.AdminCustomerWallet{
Customer: models.AdminWalletCustomer{ID: customer.ID, Name: customer.Name, Phone: customer.Phone},
Lots: []models.AdminWalletLot{},
}
wallet, err := p.admin.GetWallet(ctx, customerID)
if err != nil {
return nil, err
}
if wallet != nil {
result.PointBalance = wallet.PointBalance
result.CoinBalance = wallet.CoinBalance
}
spendable, err := p.query.SpendableBalances(ctx, customerID, now)
if err != nil {
return nil, err
}
result.SpendablePointBalance = spendable[constants.WalletCurrencyPoint]
result.SpendableCoinBalance = spendable[constants.WalletCurrencyCoin]
lots, err := p.admin.ListLotsWithBalance(ctx, customerID)
if err != nil {
return nil, err
}
for _, lot := range lots {
result.Lots = append(result.Lots, adminWalletLot(lot, now))
}
rows, total, err := p.query.ListTransactions(ctx, filter)
if err != nil {
return nil, err
}
transactions, err := p.adminTransactions(ctx, rows)
if err != nil {
return nil, err
}
result.Transactions = models.PaginatedResponse[models.AdminWalletTransaction]{
Data: transactions,
Pagination: models.Pagination{
Page: page,
Limit: filter.Limit,
Total: total,
TotalPages: int((total + int64(filter.Limit) - 1) / int64(filter.Limit)),
},
}
return result, nil
}
// Adjust adds to or takes from a customer's balance by hand. It is recorded as an
// ADJUSTMENT pointing at the admin, with the reason. Taking more than the customer
// can spend is refused with repository.ErrWalletInsufficientBalance.
func (p *WalletAdminProcessor) Adjust(ctx context.Context, organizationID, adminID, customerID uuid.UUID, in models.WalletAdjustment) (*models.AdminWalletAdjustmentResult, error) {
reason := strings.TrimSpace(in.Reason)
if adminID == uuid.Nil {
return nil, fmt.Errorf("%w: the admin making the adjustment is unknown", ErrWalletInvalidEntry)
}
if in.Amount == 0 {
return nil, fmt.Errorf("%w: amount must not be zero", ErrWalletInvalidEntry)
}
entry := WalletEntry{
CustomerID: customerID,
Currency: in.Currency,
Type: constants.WalletTxTypeAdjustment,
Amount: abs(in.Amount),
ReferenceType: constants.WalletRefTypeUser,
ReferenceID: adminID,
CreatedByUser: &adminID,
Reason: &reason,
Description: truncateRunes("Koreksi oleh admin: "+reason, walletDescriptionLimit),
}
if in.IdempotencyKey != "" {
// Scoped to the customer so a key chosen by the dashboard cannot collide with
// the keys other flows use.
entry.IdempotencyKey = fmt.Sprintf("adjust:%s:%s", customerID, in.IdempotencyKey)
}
var res *WalletResult
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if _, err := p.admin.GetCustomer(ctx, organizationID, customerID); err != nil {
return err
}
if in.Amount < 0 {
var err error
res, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: entry})
return err
}
// An adjustment that adds is a new balance, and expires like one (F7, F12).
settings, err := p.settings.Organization(ctx, organizationID)
if err != nil {
return err
}
res, err = p.wallet.Credit(ctx, WalletCreditInput{
WalletEntry: entry,
Lots: []WalletLotInput{{Amount: entry.Amount, ExpiresAt: ComputeExpiry(ExpirySettings(settings, in.Currency), p.now())}},
})
return err
})
if err != nil {
return nil, err
}
transactions, err := p.adminTransactions(ctx, []entities.WalletTransaction{*res.Transaction})
if err != nil {
return nil, err
}
spendable, err := p.query.SpendableBalances(ctx, customerID, p.now())
if err != nil {
return nil, err
}
return &models.AdminWalletAdjustmentResult{
Transaction: transactions[0],
SpendablePointBalance: spendable[constants.WalletCurrencyPoint],
SpendableCoinBalance: spendable[constants.WalletCurrencyCoin],
Replayed: res.Replayed,
}, nil
}
// adminTransactions adds, to what the customer sees, the names behind each row.
func (p *WalletAdminProcessor) adminTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.AdminWalletTransaction, error) {
base, err := buildWalletTransactions(ctx, p.query, rows)
if err != nil {
return nil, err
}
var customerIDs, userIDs, outletIDs []uuid.UUID
for _, row := range rows {
if row.CounterpartyCustomerID != nil {
customerIDs = append(customerIDs, *row.CounterpartyCustomerID)
}
if row.CreatedByUser != nil {
userIDs = append(userIDs, *row.CreatedByUser)
}
if row.OutletID != nil {
outletIDs = append(outletIDs, *row.OutletID)
}
}
customers, err := p.admin.CustomerNames(ctx, customerIDs)
if err != nil {
return nil, err
}
users, err := p.admin.UserNames(ctx, userIDs)
if err != nil {
return nil, err
}
outlets, err := p.admin.OutletNames(ctx, outletIDs)
if err != nil {
return nil, err
}
named := func(id *uuid.UUID, names map[uuid.UUID]string) *models.AdminWalletNamedRef {
if id == nil {
return nil
}
return &models.AdminWalletNamedRef{ID: *id, Name: names[*id]}
}
out := make([]models.AdminWalletTransaction, 0, len(rows))
for i, row := range rows {
out = append(out, models.AdminWalletTransaction{
CustomerWalletTransaction: base[i],
Counterparty: named(row.CounterpartyCustomerID, customers),
CreatedBy: named(row.CreatedByUser, users),
Outlet: named(row.OutletID, outlets),
Reason: row.Reason,
Metadata: row.Metadata,
})
}
return out, nil
}
func truncateRunes(s string, limit int) string {
if utf8.RuneCountInString(s) <= limit {
return s
}
return string([]rune(s)[:limit])
}