Files
apskel-pos-backend/internal/validator/wallet_validator.go
T
efrilmandClaude Opus 5.5 a6d5a8b056 feat(wallet): customer wallet and manual adjustments in the dashboard
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>
2026-09-30 09:36:18 +07:00

67 lines
2.3 KiB
Go

package validator
import (
"errors"
"strings"
"unicode/utf8"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
)
// walletAdjustmentReasonLimit is the size of wallet_transactions.reason.
const walletAdjustmentReasonLimit = 255
// walletCashOutWords are reasons that describe turning balance into money. EnakPoint
// and EnakCoin can never be cashed out, and an adjustment must not be used to record
// that it happened (docs/prd-point-coin.md K7). Words like "tunai" are left out on
// purpose: "paid in cash but the points never arrived" is a legitimate reason.
var walletCashOutWords = []string{"pencairan", "cairkan", "cash out", "cashout", "withdraw"}
type WalletValidator interface {
ValidateAdjustRequest(req *contract.AdjustCustomerWalletRequest) (error, string)
}
type WalletValidatorImpl struct{}
func NewWalletValidator() *WalletValidatorImpl {
return &WalletValidatorImpl{}
}
func (v *WalletValidatorImpl) ValidateAdjustRequest(req *contract.AdjustCustomerWalletRequest) (error, string) {
if req == nil {
return errors.New("request body is required"), constants.MissingFieldErrorCode
}
req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
if req.Currency == "" {
return errors.New("currency is required"), constants.MissingFieldErrorCode
}
if !constants.IsValidWalletCurrency(req.Currency) {
return errors.New("currency must be POINT or COIN"), constants.MalformedFieldErrorCode
}
if req.Amount == 0 {
return errors.New("amount must not be zero: use a positive amount to add and a negative one to take away"), constants.ValidationErrorCode
}
req.Reason = strings.TrimSpace(req.Reason)
if req.Reason == "" {
return errors.New("reason is required"), constants.MissingFieldErrorCode
}
if utf8.RuneCountInString(req.Reason) > walletAdjustmentReasonLimit {
return errors.New("reason must be at most 255 characters"), constants.MalformedFieldErrorCode
}
lower := strings.ToLower(req.Reason)
for _, word := range walletCashOutWords {
if strings.Contains(lower, word) {
return errors.New("EnakPoint and EnakCoin cannot be cashed out, so an adjustment cannot be for a cash-out"), constants.ValidationErrorCode
}
}
if len(req.IdempotencyKey) > 50 {
return errors.New("idempotency_key must be at most 50 characters"), constants.MalformedFieldErrorCode
}
return nil, ""
}