Files
apskel-pos-backend/internal/processor/customer_pin_processor.go
T
efrilmandClaude Opus 5.5 798a36bd6c feat(enakgame): game sessions, rewards, vouchers, budgets and events
EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the
existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md).

Foundation (phase 1)
- Migrations 000103-000106: games extended with organization, slug, status,
  entry cost and result rules, old games archived (not deleted); budgets,
  versioned reward configs, sessions and session rewards; the ledger types
  GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs.
- AuditLogger writes in the caller's transaction only.
- enakgame.limit.user_daily and global_daily organization settings.

Games and sessions (phases 2-4)
- Admin /marketing/enakgame: games, reward config versions (immutable but for
  status, one ACTIVE per game), budgets with non-overlapping global periods and
  a daily job opening the next month.
- Customer /customer/enakgame: start (Idempotency-Key, entry cost and config
  frozen on the session), complete (result validation, reward engine, max_reward
  cap, daily limits via game_reward_counters, one GAME_REWARD per budget),
  automatic refunds for system errors and deactivated games, and a session job.
- Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand),
  rounded down.

Vouchers and budgets (phases 5-6)
- Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution;
  Economy Guard counters.
- STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action;
  realized cost traced through the lots to the budget that paid the reward.
- Budget metrics: realized cost, forecast, exposure and status. Migrations
  000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY.

Events (phase 7)
- Migration 000111: game events, each with its own EVENT budget. Event extras
  stack per PRD §16 defaults, with event and per-customer limits.

External vouchers (phase 8)
- VoucherProvider contract, two-step PENDING redemption and a recovery job,
  tested with a fake provider. No provider adapter is registered yet, so
  EXTERNAL vouchers stay out of the catalog.

Not yet decided before release: reward rounding, event stacking, budget
exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have
not been run on any shared database.

Also fixes a leftover PAYMENT filter in a wallet test and a data race in a
test PIN fake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:53:14 +07:00

478 lines
17 KiB
Go

package processor
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// PIN rules (docs/prd-point-coin.md F11, Q16, Q17).
const (
pinLength = 6
pinMaxAttempts = 5
pinLockDuration = 30 * time.Minute
pinTransferHold = 24 * time.Hour
pinSecurityReasonN = 255
PinOtpPurposeSetup = "pin_setup"
PinOtpPurposeReset = "pin_reset"
)
// Security log events.
const (
PinEventSet = "PIN_SET"
PinEventChanged = "PIN_CHANGED"
PinEventReset = "PIN_RESET"
PinEventFailed = "PIN_FAILED"
PinEventLocked = "PIN_LOCKED"
PinEventRemovedByAdmin = "PIN_REMOVED_BY_ADMIN"
)
// What a PIN approves. Only a transfer is held after a reset.
type PinAction string
const (
PinActionPay PinAction = "PAY"
PinActionExchange PinAction = "EXCHANGE"
PinActionTransfer PinAction = "TRANSFER"
// Redeeming EnakPoint for an EnakGame voucher (docs/rfc-enakgame.md §7.4).
PinActionRedeem PinAction = "REDEEM"
)
type pinVerifier interface {
VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error
}
// Codes of PinError, which the apps tell apart (docs/prd-point-coin.md §9).
const (
PinErrNotSet = "PIN_NOT_SET"
PinErrInvalid = "PIN_INVALID"
PinErrLocked = "PIN_LOCKED"
PinErrTransferBlocked = "TRANSFER_BLOCKED"
)
// PinError is why a PIN did not approve an action.
type PinError struct {
Code string
// Set for PIN_INVALID: attempts left before the PIN locks.
RemainingAttempts int
// Set for PIN_LOCKED and TRANSFER_BLOCKED.
Until *time.Time
}
func (e *PinError) Error() string {
switch e.Code {
case PinErrNotSet:
return "PIN has not been set"
case PinErrInvalid:
return fmt.Sprintf("wrong PIN, %d attempts left", e.RemainingAttempts)
case PinErrLocked:
return fmt.Sprintf("PIN is locked until %s", e.Until.Format(time.RFC3339))
case PinErrTransferBlocked:
return fmt.Sprintf("transfers are on hold after a PIN reset until %s", e.Until.Format(time.RFC3339))
}
return e.Code
}
var (
// ErrInvalidPinInput wraps a PIN that is malformed, weak, or not confirmed. The
// message never contains the PIN.
ErrInvalidPinInput = errors.New("invalid PIN")
// ErrPinAlreadySet means a first PIN was requested for a customer who has one.
ErrPinAlreadySet = errors.New("PIN has already been set")
// ErrPinOtpInvalid means the OTP was wrong, expired, used, for another purpose, or
// sent to another number.
ErrPinOtpInvalid = errors.New("invalid or expired OTP")
// ErrPinOtpTooSoon means an OTP was requested again too quickly.
ErrPinOtpTooSoon = errors.New("an OTP was sent recently; wait before asking again")
// ErrPinNoPhone means the customer has no phone number to send an OTP to.
ErrPinNoPhone = errors.New("customer has no phone number")
)
type pinOtpSender interface {
CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error)
CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
}
// NotificationTypePinLocked is the data type of the push a customer gets when their
// PIN locks, so the app can offer the PIN reset.
const NotificationTypePinLocked = "PIN_LOCKED"
// CustomerPinProcessor manages customer PINs (docs/prd-point-coin.md F11). Every flow
// that moves balance on the customer's request calls VerifyPin first (K8).
type CustomerPinProcessor struct {
repo repository.CustomerPinRepository
otp pinOtpSender
notifier customerNotifier
now func() time.Time
cost int
}
func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, notifier customerNotifier) *CustomerPinProcessor {
return &CustomerPinProcessor{repo: repo, otp: otp, notifier: notifier, now: time.Now, cost: bcrypt.DefaultCost}
}
func (p *CustomerPinProcessor) Status(ctx context.Context, customerID uuid.UUID) (*models.CustomerPinStatus, error) {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return nil, err
}
now := p.now()
status := &models.CustomerPinStatus{HasPin: state.PinHash != nil}
if state.LockedUntil != nil && state.LockedUntil.After(now) {
status.LockedUntil = state.LockedUntil
}
if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) {
status.TransferBlockedUntil = state.TransferBlockedUntil
}
return status, nil
}
// RequestOtp sends an OTP to the customer's own phone number, for creating a first PIN
// (pin_setup) or resetting a forgotten one (pin_reset).
func (p *CustomerPinProcessor) RequestOtp(ctx context.Context, customerID uuid.UUID, purpose string) (*models.CustomerPinOtp, error) {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return nil, err
}
switch purpose {
case PinOtpPurposeSetup:
if state.PinHash != nil {
return nil, ErrPinAlreadySet
}
case PinOtpPurposeReset:
if state.PinHash == nil {
return nil, &PinError{Code: PinErrNotSet}
}
default:
return nil, fmt.Errorf("%w: purpose must be %s or %s", ErrInvalidPinInput, PinOtpPurposeSetup, PinOtpPurposeReset)
}
if state.PhoneNumber == nil || *state.PhoneNumber == "" {
return nil, ErrPinNoPhone
}
canSend, _, err := p.otp.CanResendOtp(ctx, *state.PhoneNumber, purpose)
if err != nil {
return nil, err
}
if !canSend {
return nil, ErrPinOtpTooSoon
}
session, err := p.otp.CreateOtpSession(ctx, *state.PhoneNumber, purpose)
if err != nil {
return nil, err
}
if err := p.otp.SendOtpViaWhatsApp(*state.PhoneNumber, session.Code, purpose); err != nil {
return nil, err
}
return &models.CustomerPinOtp{Purpose: purpose, OtpToken: session.Token, ExpiresAt: session.ExpiresAt}, nil
}
// CreatePin sets a customer's first PIN, approved by an OTP to their phone so it is set
// by the owner of the number and not by whoever holds a logged-in phone.
func (p *CustomerPinProcessor) CreatePin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
if state.PinHash != nil {
return ErrPinAlreadySet
}
// Check the PIN before spending the OTP, so a weak PIN does not cost a new code.
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
return err
}
if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeSetup); err != nil {
return err
}
hash, err := p.hash(pin)
if err != nil {
return err
}
if err := p.repo.SetPin(ctx, customerID, hash, nil); err != nil {
return err
}
p.logEvent(ctx, customerID, PinEventSet, nil, nil, info)
return nil
}
// ChangePin replaces the PIN after checking the old one, which counts toward the lock
// like any other attempt. A transfer hold from an earlier reset stays.
func (p *CustomerPinProcessor) ChangePin(ctx context.Context, customerID uuid.UUID, oldPin, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
return err
}
if err := p.verify(ctx, state, oldPin, PinActionPay, info); err != nil {
return err
}
hash, err := p.hash(pin)
if err != nil {
return err
}
if err := p.repo.SetPin(ctx, customerID, hash, p.activeHold(state)); err != nil {
return err
}
p.logEvent(ctx, customerID, PinEventChanged, nil, nil, info)
return nil
}
// ResetPin sets a new PIN for a customer who forgot theirs, approved by an OTP. It also
// lifts a lock, and holds outgoing transfers for 24 hours in case the phone number was
// taken over (Q16).
func (p *CustomerPinProcessor) ResetPin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
if state.PinHash == nil {
return &PinError{Code: PinErrNotSet}
}
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
return err
}
if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeReset); err != nil {
return err
}
hash, err := p.hash(pin)
if err != nil {
return err
}
hold := p.now().Add(pinTransferHold)
if err := p.repo.SetPin(ctx, customerID, hash, &hold); err != nil {
return err
}
p.logEvent(ctx, customerID, PinEventReset, nil, nil, info)
return nil
}
// VerifyPin checks the PIN before an action that moves balance. It returns a *PinError
// with the code the apps act on: PIN_NOT_SET, PIN_INVALID (with the attempts left),
// PIN_LOCKED or TRANSFER_BLOCKED (with until when).
func (p *CustomerPinProcessor) VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
return p.verify(ctx, state, pin, action, info)
}
func (p *CustomerPinProcessor) verify(ctx context.Context, state *repository.CustomerPinState, pin string, action PinAction, info models.CustomerPinRequestInfo) error {
if state.PinHash == nil {
return &PinError{Code: PinErrNotSet}
}
now := p.now()
// A locked PIN is refused before it is compared, even when it is right.
if state.LockedUntil != nil && state.LockedUntil.After(now) {
until := *state.LockedUntil
return &PinError{Code: PinErrLocked, Until: &until}
}
// A held transfer is refused before the PIN is compared, so it costs no attempt.
if action == PinActionTransfer && state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) {
until := *state.TransferBlockedUntil
return &PinError{Code: PinErrTransferBlocked, Until: &until}
}
if bcrypt.CompareHashAndPassword([]byte(*state.PinHash), []byte(pin)) != nil {
attempts, lockedUntil, err := p.repo.RecordFailure(ctx, state.CustomerID, pinMaxAttempts, now, now.Add(pinLockDuration))
if err != nil {
return err
}
p.logEvent(ctx, state.CustomerID, PinEventFailed, nil, nil, info)
if lockedUntil != nil && lockedUntil.After(now) {
// Only the attempt that reached the limit logs the lock and tells the
// customer; attempts racing it just see the lock.
if attempts == pinMaxAttempts {
p.logEvent(ctx, state.CustomerID, PinEventLocked, nil, nil, info)
p.alertLocked(ctx, state, *lockedUntil)
}
return &PinError{Code: PinErrLocked, Until: lockedUntil}
}
return &PinError{Code: PinErrInvalid, RemainingAttempts: pinMaxAttempts - attempts}
}
if state.FailedAttempts > 0 || state.LockedUntil != nil {
if err := p.repo.ClearFailures(ctx, state.CustomerID); err != nil {
return err
}
}
return nil
}
// RemovePinByAdmin deletes a customer's PIN, for example when they lost access to it,
// so they have to create a new one through OTP. Admins can never set or read a PIN.
func (p *CustomerPinProcessor) RemovePinByAdmin(ctx context.Context, organizationID, customerID, adminID uuid.UUID, reason string, info models.CustomerPinRequestInfo) error {
reason = strings.TrimSpace(reason)
if reason == "" {
return fmt.Errorf("%w: a reason is required", ErrInvalidPinInput)
}
if adminID == uuid.Nil {
return fmt.Errorf("%w: the admin is unknown", ErrInvalidPinInput)
}
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
if state.OrganizationID != organizationID {
return repository.ErrPinCustomerNotFound
}
if state.PinHash == nil {
return &PinError{Code: PinErrNotSet}
}
if err := p.repo.RemovePin(ctx, customerID); err != nil {
return err
}
reason = truncateRunes(reason, pinSecurityReasonN)
p.logEvent(ctx, customerID, PinEventRemovedByAdmin, &adminID, &reason, info)
return nil
}
// ListEvents returns a page of a customer's PIN security log for the dashboard.
func (p *CustomerPinProcessor) ListEvents(ctx context.Context, organizationID, customerID uuid.UUID, page, limit int) (*models.PaginatedResponse[models.CustomerSecurityEventView], error) {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return nil, err
}
if state.OrganizationID != organizationID {
return nil, repository.ErrPinCustomerNotFound
}
if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 20
}
rows, total, err := p.repo.ListEvents(ctx, customerID, (page-1)*limit, limit)
if err != nil {
return nil, err
}
events := make([]models.CustomerSecurityEventView, 0, len(rows))
for _, e := range rows {
events = append(events, models.CustomerSecurityEventView{
ID: e.ID, Event: e.Event, ActorUser: e.ActorUser, Reason: e.Reason,
IPAddress: e.IPAddress, UserAgent: e.UserAgent, CreatedAt: e.CreatedAt,
})
}
return &models.PaginatedResponse[models.CustomerSecurityEventView]{
Data: events,
Pagination: models.Pagination{
Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)),
},
}, nil
}
// checkOtp validates an OTP and that it was issued for this purpose to this customer's
// own phone number. Without those checks an OTP from the login flow, or one sent to
// another number, could approve a PIN change.
func (p *CustomerPinProcessor) checkOtp(ctx context.Context, state *repository.CustomerPinState, token, code, purpose string) error {
if token == "" || code == "" || state.PhoneNumber == nil {
return ErrPinOtpInvalid
}
session, err := p.otp.ValidateOtpSession(ctx, token, code)
if err != nil || session == nil {
return ErrPinOtpInvalid
}
if session.Purpose != purpose || session.PhoneNumber != *state.PhoneNumber {
return ErrPinOtpInvalid
}
return nil
}
func (p *CustomerPinProcessor) hash(pin string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(pin), p.cost)
if err != nil {
return "", fmt.Errorf("failed to hash PIN: %w", err)
}
return string(hash), nil
}
func (p *CustomerPinProcessor) activeHold(state *repository.CustomerPinState) *time.Time {
if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(p.now()) {
return state.TransferBlockedUntil
}
return nil
}
// logEvent records a security event. The log is best effort: failing to write it must
// not undo what the customer just did, so a failure is logged instead.
func (p *CustomerPinProcessor) logEvent(ctx context.Context, customerID uuid.UUID, event string, actor *uuid.UUID, reason *string, info models.CustomerPinRequestInfo) {
e := repository.CustomerSecurityEvent{CustomerID: customerID, Event: event, ActorUser: actor, Reason: reason}
if info.IPAddress != "" {
ip := truncateRunes(info.IPAddress, 45)
e.IPAddress = &ip
}
if info.UserAgent != "" {
ua := truncateRunes(info.UserAgent, 255)
e.UserAgent = &ua
}
if err := p.repo.InsertEvent(ctx, e); err != nil {
logger.NonContext.Error(fmt.Sprintf("Could not record %s for customer %s", event, customerID), err)
}
}
// alertLocked pushes the lock to the customer's app through FCM (F11). It is best
// effort: the lock stands whether or not the push goes out.
func (p *CustomerPinProcessor) alertLocked(ctx context.Context, state *repository.CustomerPinState, until time.Time) {
if p.notifier == nil {
return
}
body := fmt.Sprintf("PIN EnakPoint kamu terkunci sampai %s karena salah dimasukkan %d kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.",
until.In(walletDisplayLocation).Format("02 Jan 2006 15:04 WIB"), pinMaxAttempts)
data := map[string]string{
"type": NotificationTypePinLocked,
"locked_until": until.UTC().Format(time.RFC3339),
}
if err := p.notifier.Notify(ctx, state.CustomerID, "PIN terkunci", body, data); err != nil {
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s their PIN is locked", state.CustomerID), err)
}
}
// checkNewPin rejects a PIN that is not 6 digits, does not match its confirmation, or
// is easy to guess: one digit repeated, a run up or down, or the birth date as DDMMYY
// or YYMMDD.
func checkNewPin(pin, confirm string, birthDate *time.Time) error {
if len(pin) != pinLength {
return fmt.Errorf("%w: a PIN is %d digits", ErrInvalidPinInput, pinLength)
}
for _, r := range pin {
if r < '0' || r > '9' {
return fmt.Errorf("%w: a PIN is digits only", ErrInvalidPinInput)
}
}
if pin != confirm {
return fmt.Errorf("%w: the PIN and its confirmation differ", ErrInvalidPinInput)
}
same, up, down := true, true, true
for i := 1; i < len(pin); i++ {
d := int(pin[i]) - int(pin[i-1])
same = same && d == 0
up = up && d == 1
down = down && d == -1
}
if same || up || down {
return fmt.Errorf("%w: the PIN is too easy to guess", ErrInvalidPinInput)
}
if birthDate != nil {
for _, layout := range []string{"020106", "060102"} {
if pin == birthDate.Format(layout) {
return fmt.Errorf("%w: the PIN must not be your birth date", ErrInvalidPinInput)
}
}
}
return nil
}