Files
apskel-pos-backend/internal/processor/wallet_admin_processor.go
T
efrilmandClaude Opus 5.5 8bf1d5c1a8 feat(loyalty): trace a wallet row lot by lot in the dashboard
Adds GET /marketing/wallet-transactions/:id/trace (docs/prd-point-coin.md
F7, §8.1, PC-404).

From any ledger row of the organization, the trace lists the lots a debit
took from, with how much it took from each, or the lots a credit created.
Each lot is followed back through origin_lot_id, across transfers,
exchanges and refunds, to the lot an EARN, ADJUSTMENT or MIGRATION first
created. Every step shows the lot and the row that created it, with the
real name of the customer it belongs to, so the example of §8 (A sends 120
to B, B pays 30) leads from B's payment to A's order #ORD-1.

Lots are loaded a generation at a time, and a chain stops at 100 steps or
at a lot it has already seen, which only bad data could cause. A row of
another organization answers 404.

The dashboard's wallet view now builds its lots with the same helper.

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

216 lines
6.8 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
tx TxRunner
now func() time.Time
}
func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, tx TxRunner) *WalletAdminProcessor {
return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, 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
}
var err error
if in.Amount > 0 {
// Adjustments never expire until expiry is decided (F12, note N4).
res, err = p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: entry})
} else {
res, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: entry})
}
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])
}