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>
216 lines
6.8 KiB
Go
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])
|
|
}
|