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>
This commit is contained in:
efrilm
2026-10-07 20:53:14 +07:00
co-authored by Claude Opus 5.5
parent 2c9753fae7
commit 798a36bd6c
92 changed files with 12392 additions and 23 deletions
@@ -0,0 +1,196 @@
package processor
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
)
const (
// voucherProviderTimeout bounds one call to a provider. A call that runs out is an
// unknown outcome, settled later by the recovery job.
voucherProviderTimeout = 10 * time.Second
// voucherRecoveryAfter is how long a PENDING redemption waits before the recovery
// job asks its provider again.
voucherRecoveryAfter = 2 * time.Minute
// voucherMaxAttempts is how many calls a redemption gets before the job gives up,
// refunds it and marks it FAILED.
voucherMaxAttempts = 10
// voucherRecoveryBatch is how many PENDING redemptions one run of the job claims.
voucherRecoveryBatch = 50
)
// issueExternal asks an EXTERNAL voucher's provider for the voucher of a PENDING
// redemption, outside any transaction, and settles the redemption with the answer
// (docs/rfc-enakgame.md §7.5). With lookup, it first asks whether the provider already
// issued it, and only asks to issue when the provider never got the request.
//
// The call does not stop when the customer's request does: an answer the provider
// gave must not be thrown away.
func (p *VoucherRedemptionProcessor) issueExternal(ctx context.Context, r *entities.VoucherRedemption, v *entities.Voucher, lookup bool) (*entities.VoucherRedemption, error) {
provider, ok := p.providerOf(v)
var issue *VoucherIssue
var err error
if ok {
req := VoucherIssueRequest{RedemptionID: r.ID, ProviderRef: v.ProviderRef, VoucherID: v.ID, FaceValue: r.FaceValue, CustomerID: r.CustomerID}
callCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), voucherProviderTimeout)
if lookup {
issue, err = provider.Lookup(callCtx, req)
}
if !lookup || classifyVoucherIssue(issue, err) == voucherNotFound {
issue, err = provider.Issue(callCtx, req)
}
cancel()
} else {
err = fmt.Errorf("no adapter for provider %q", providerName(v))
}
outcome := classifyVoucherIssue(issue, err)
if outcome == voucherUnknown || outcome == voucherNotFound {
logger.FromContext(ctx).WithError(err).Warn("Voucher provider gave no answer; the redemption stays PENDING")
}
return p.settleExternal(ctx, r.ID, outcome, issue, err)
}
// settleExternal applies a provider's answer to a redemption that is still PENDING,
// under its customer's wallet lock: COMPLETED with the voucher and its cost split, or
// FAILED with its EnakPoint refunded. An unknown answer leaves it PENDING, until it
// has had voucherMaxAttempts calls; then it is refunded too. A redemption no longer
// PENDING is left as it is, so settling twice refunds nothing twice.
func (p *VoucherRedemptionProcessor) settleExternal(ctx context.Context, id uuid.UUID, outcome voucherIssueOutcome, issue *VoucherIssue, callErr error) (*entities.VoucherRedemption, error) {
var settled *entities.VoucherRedemption
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
r, err := p.redemptions.GetRedemption(ctx, id)
if err != nil {
return err
}
if err := p.wallet.LockWallet(ctx, r.CustomerID); err != nil {
return err
}
if r, err = p.redemptions.GetRedemption(ctx, id); err != nil {
return err
}
settled = r
if r.Status != constants.VoucherRedemptionPending {
return nil
}
now := p.now()
switch outcome {
case voucherIssued:
code, ref := issue.Code, issue.Ref
var refPtr *string
if ref != "" {
refPtr = &ref
}
moved, err := p.redemptions.MarkCompleted(ctx, r.ID, &code, refPtr, now)
if err != nil || !moved {
return err
}
r.Status, r.ExternalCode, r.ExternalRef, r.CompletedAt = constants.VoucherRedemptionCompleted, &code, refPtr, &now
return p.recordCost(ctx, r)
case voucherRejected:
return p.refundExternal(ctx, r, "the provider refused: "+callErr.Error())
}
attempts, err := p.redemptions.TouchPending(ctx, r.ID)
if err != nil {
return err
}
r.Attempts = attempts
if attempts >= voucherMaxAttempts {
return p.refundExternal(ctx, r, fmt.Sprintf("the provider did not confirm the voucher after %d attempts", attempts))
}
return nil
})
if err != nil {
return nil, err
}
return settled, nil
}
// refundExternal gives back the EnakPoint of a redemption that will not complete and
// marks it FAILED (§7.5). The refund keeps the expiry of the lots the debit drew from,
// with at least seven days left (§6.3).
func (p *VoucherRedemptionProcessor) refundExternal(ctx context.Context, r *entities.VoucherRedemption, reason string) error {
lots, err := p.wallet.RefundLots(ctx, r.DebitTransactionID)
if err != nil {
return err
}
debitID := r.DebitTransactionID
refund, err := p.wallet.Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{
CustomerID: r.CustomerID,
Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeRewardRedeemRefund,
Amount: r.PointCost,
ReferenceType: constants.WalletRefTypeRewardRedemption,
ReferenceID: r.ID,
ReversesTransactionID: &debitID,
Description: "Pengembalian EnakPoint voucher",
Metadata: entities.Metadata{"voucher_id": r.VoucherID.String(), "reason": reason},
IdempotencyKey: "redeem-refund:" + r.ID.String(),
},
Lots: lots,
})
if err != nil {
return err
}
moved, err := p.redemptions.MarkFailed(ctx, r.ID, refund.Transaction.ID, reason)
if err != nil {
return err
}
if !moved {
return errors.New("voucher redemption left PENDING during its refund")
}
r.Status, r.RefundTransactionID, r.FailureReason = constants.VoucherRedemptionFailed, &refund.Transaction.ID, &reason
return nil
}
// RecoverPending is the recovery job (§7.5 step 4): it asks the provider again about
// every redemption PENDING for longer than voucherRecoveryAfter, and settles it. It
// returns how many it completed and how many it failed.
func (p *VoucherRedemptionProcessor) RecoverPending(ctx context.Context) (completed, failed int, err error) {
claimed, err := p.redemptions.ClaimStalePending(ctx, p.now().Add(-voucherRecoveryAfter), voucherRecoveryBatch)
if err != nil {
return 0, 0, err
}
var failures []error
for i := range claimed {
r := &claimed[i]
v, err := p.vouchers.GetVoucher(ctx, r.OrganizationID, r.VoucherID)
if err == nil {
r, err = p.issueExternal(ctx, r, v, true)
}
if err != nil {
failures = append(failures, fmt.Errorf("redemption %s: %w", claimed[i].ID, err))
continue
}
switch r.Status {
case constants.VoucherRedemptionCompleted:
completed++
case constants.VoucherRedemptionFailed:
failed++
}
}
return completed, failed, errors.Join(failures...)
}
func (p *VoucherRedemptionProcessor) providerOf(v *entities.Voucher) (VoucherProvider, bool) {
if v.Provider == nil {
return nil, false
}
provider, ok := p.providers[*v.Provider]
return provider, ok
}
func providerName(v *entities.Voucher) string {
if v.Provider == nil {
return ""
}
return *v.Provider
}