224 lines
7.2 KiB
Go
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])
|
||
|
|
}
|