Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)

This reverts commit 4e24f9bbb0.
This commit is contained in:
efrilm
2026-09-30 15:31:11 +07:00
parent 5e28b05770
commit c6062c4bb9
195 changed files with 20058 additions and 2177 deletions
@@ -0,0 +1,223 @@
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])
}