Adds the dashboard side of a customer's wallet (docs/prd-point-coin.md F7, PC-107), under /marketing for admins and managers: - GET /marketing/customers/:id/wallet returns the customer, the ledger and spendable balances, every lot that still holds something (flagged when expired), and a page of history. Unlike the customer's own view, each row carries the real names behind it: the transfer counterparty, the admin or cashier, and the outlet, plus the reason and metadata. - POST /marketing/customers/:id/wallet/adjust takes a signed amount and a required reason. It writes an ADJUSTMENT pointing at the admin through the wallet engine, refuses to take more than the customer can spend, and accepts an optional idempotency key so a retried request adjusts once. Reasons describing a cash-out are refused (K7). The customer must belong to the caller's organization; otherwise both endpoints answer 404. Positive adjustments create non-expiring lots until the expiry model is decided (F12, note N4). The mapping from ledger rows to what the apps show is now shared between the customer and dashboard views. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
226 lines
7.2 KiB
Go
226 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
|
|
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, models.AdminWalletLot{
|
|
ID: lot.ID,
|
|
Currency: lot.Currency,
|
|
OriginalAmount: lot.OriginalAmount,
|
|
RemainingAmount: lot.RemainingAmount,
|
|
ExpiresAt: lot.ExpiresAt,
|
|
Expired: lot.ExpiresAt != nil && !lot.ExpiresAt.After(now),
|
|
SourceTransactionID: lot.SourceTransactionID,
|
|
OriginLotID: lot.OriginLotID,
|
|
CreatedAt: lot.CreatedAt,
|
|
})
|
|
}
|
|
|
|
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])
|
|
}
|