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]) }