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:
co-authored by
Claude Opus 5.5
parent
2c9753fae7
commit
798a36bd6c
@@ -0,0 +1,343 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ErrVoucherRedemptionRejected wraps every reason a customer cannot redeem a voucher:
|
||||
// the key, the voucher's status or dates, the stock, the per-customer limit, the
|
||||
// EnakPoint balance. The message says which.
|
||||
var ErrVoucherRedemptionRejected = errors.New("voucher redemption refused")
|
||||
|
||||
// catalogStockModes are the vouchers the catalog lists. An EXTERNAL one is listed only
|
||||
// when its provider has an adapter.
|
||||
var catalogStockModes = []string{constants.VoucherStockStatic, constants.VoucherStockCodePool, constants.VoucherStockExternal}
|
||||
|
||||
func redemptionRejected(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrVoucherRedemptionRejected, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// VoucherRedemptionProcessor redeems EnakPoint for vouchers and records what each
|
||||
// redemption cost each budget (docs/rfc-enakgame.md §7.4, §7.6, D5).
|
||||
type VoucherRedemptionProcessor struct {
|
||||
customers gameCustomerReader
|
||||
vouchers repository.VoucherRepository
|
||||
redemptions repository.VoucherRedemptionRepository
|
||||
pins pinVerifier
|
||||
spendable spendableReader
|
||||
wallet *WalletProcessor
|
||||
providers VoucherProviders
|
||||
tx TxRunner
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewVoucherRedemptionProcessor(customers gameCustomerReader, vouchers repository.VoucherRepository, redemptions repository.VoucherRedemptionRepository,
|
||||
pins pinVerifier, spendable spendableReader, wallet *WalletProcessor, providers VoucherProviders, tx TxRunner) *VoucherRedemptionProcessor {
|
||||
return &VoucherRedemptionProcessor{
|
||||
customers: customers, vouchers: vouchers, redemptions: redemptions, pins: pins, spendable: spendable,
|
||||
wallet: wallet, providers: providers, tx: tx, now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Redeem takes a voucher's point cost in EnakPoint and hands out the voucher, in one
|
||||
// transaction (§7.4): the stock, the code, the debit, the redemption and its cost
|
||||
// split all commit together or not at all. The PIN approves it (K8).
|
||||
//
|
||||
// An EXTERNAL voucher comes from its provider, which is never called inside a
|
||||
// transaction (§7.5): the redemption is written PENDING with its debit first, the
|
||||
// provider is asked, and its answer completes it, fails and refunds it, or, when
|
||||
// there is no answer, leaves it PENDING for the recovery job.
|
||||
//
|
||||
// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns
|
||||
// the first redemption, with its code, and takes nothing more.
|
||||
func (p *VoucherRedemptionProcessor) Redeem(ctx context.Context, customerID, voucherID uuid.UUID, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.VoucherRedeemResult, error) {
|
||||
key := strings.TrimSpace(idempotencyKey)
|
||||
if key == "" {
|
||||
return nil, redemptionRejected("the Idempotency-Key header is required")
|
||||
}
|
||||
if len(key) > gameSessionKeyLimit {
|
||||
return nil, redemptionRejected("the Idempotency-Key header must be at most %d characters", gameSessionKeyLimit)
|
||||
}
|
||||
customer, err := p.customers.GetCustomer(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A retry is answered before anything else is checked: the voucher may have run
|
||||
// out or ended since, but this redemption went through.
|
||||
if previous, err := p.redemptions.GetByKey(ctx, customerID, key); err != nil || previous != nil {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.replay(ctx, previous, voucherID)
|
||||
}
|
||||
if !customer.IsActive {
|
||||
return nil, redemptionRejected("the customer is not active")
|
||||
}
|
||||
voucher, err := p.vouchers.GetVoucher(ctx, customer.OrganizationID, voucherID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Refuse what cannot work before the PIN is checked, so it costs no attempt.
|
||||
if err := p.redeemable(voucher, p.now()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionRedeem, info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var redemption *entities.VoucherRedemption
|
||||
var code *entities.VoucherCode
|
||||
replayed := false
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
if err := p.wallet.LockWallet(ctx, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Under the lock, a request sent twice at once finds the first one here.
|
||||
previous, err := p.redemptions.GetByKey(ctx, customerID, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if previous != nil {
|
||||
redemption, replayed = previous, true
|
||||
return nil
|
||||
}
|
||||
if voucher, err = p.vouchers.GetVoucher(ctx, customer.OrganizationID, voucherID); err != nil {
|
||||
return err
|
||||
}
|
||||
now := p.now()
|
||||
if err := p.redeemable(voucher, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if voucher.MaxPerCustomer != nil {
|
||||
// Counted under the wallet lock, so the customer's own redemptions at the
|
||||
// same time see each other.
|
||||
count, err := p.redemptions.CountForCustomer(ctx, customerID, voucher.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= int64(*voucher.MaxPerCustomer) {
|
||||
return redemptionRejected("this voucher can be redeemed at most %d times per customer", *voucher.MaxPerCustomer)
|
||||
}
|
||||
}
|
||||
|
||||
redemptionID := uuid.New()
|
||||
switch voucher.StockMode {
|
||||
case constants.VoucherStockStatic:
|
||||
taken, err := p.vouchers.TakeStock(ctx, voucher.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !taken {
|
||||
return redemptionRejected("the voucher is out of stock")
|
||||
}
|
||||
case constants.VoucherStockCodePool:
|
||||
if code, err = p.vouchers.ClaimCode(ctx, voucher.ID, redemptionID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if code == nil {
|
||||
return redemptionRejected("the voucher is out of stock")
|
||||
}
|
||||
}
|
||||
external := voucher.StockMode == constants.VoucherStockExternal
|
||||
|
||||
debit, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: customerID,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeRewardRedeem,
|
||||
Amount: voucher.PointCost,
|
||||
ReferenceType: constants.WalletRefTypeRewardRedemption,
|
||||
ReferenceID: redemptionID,
|
||||
Description: truncateDescription("Tukar voucher " + voucher.Name),
|
||||
Metadata: entities.Metadata{"voucher_id": voucher.ID.String(), "face_value": voucher.FaceValue},
|
||||
IdempotencyKey: "redeem:" + redemptionID.String(),
|
||||
}})
|
||||
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
|
||||
return redemptionRejected("not enough EnakPoint")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
redemption = &entities.VoucherRedemption{
|
||||
ID: redemptionID,
|
||||
OrganizationID: customer.OrganizationID,
|
||||
CustomerID: customerID,
|
||||
VoucherID: voucher.ID,
|
||||
IdempotencyKey: key,
|
||||
Status: constants.VoucherRedemptionCompleted,
|
||||
FaceValue: voucher.FaceValue,
|
||||
PointCost: voucher.PointCost,
|
||||
DebitTransactionID: debit.Transaction.ID,
|
||||
CompletedAt: &now,
|
||||
}
|
||||
if external {
|
||||
// Its cost is split once the provider has issued it.
|
||||
redemption.Status, redemption.CompletedAt = constants.VoucherRedemptionPending, nil
|
||||
}
|
||||
if code != nil {
|
||||
redemption.VoucherCodeID = &code.ID
|
||||
}
|
||||
if err := p.redemptions.CreateRedemption(ctx, redemption); err != nil {
|
||||
return err
|
||||
}
|
||||
if external {
|
||||
return nil
|
||||
}
|
||||
return p.recordCost(ctx, redemption)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if replayed {
|
||||
return p.replay(ctx, redemption, voucherID)
|
||||
}
|
||||
if redemption.Status == constants.VoucherRedemptionPending {
|
||||
if redemption, err = p.issueExternal(ctx, redemption, voucher, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return p.result(ctx, redemption, voucher, code, false)
|
||||
}
|
||||
|
||||
// recordCost splits a completed redemption's face value over where its EnakPoint
|
||||
// came from and freezes the split (§7.6). Only the part from EnakGame rewards names
|
||||
// a budget; the rest is kept for reporting.
|
||||
func (p *VoucherRedemptionProcessor) recordCost(ctx context.Context, redemption *entities.VoucherRedemption) error {
|
||||
sources, err := p.redemptions.PointSources(ctx, redemption.DebitTransactionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var traced int64
|
||||
for _, s := range sources {
|
||||
traced += s.Points
|
||||
}
|
||||
if traced != redemption.PointCost {
|
||||
return fmt.Errorf("redemption %s: traced %d EnakPoint back to their source, not %d", redemption.ID, traced, redemption.PointCost)
|
||||
}
|
||||
parts := SplitVoucherCost(redemption.FaceValue, sources)
|
||||
costs := make([]entities.VoucherRedemptionCost, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
costs = append(costs, entities.VoucherRedemptionCost{
|
||||
RedemptionID: redemption.ID, BudgetID: part.BudgetID, SourceType: part.SourceType,
|
||||
Points: part.Points, Cost: part.Cost, RecognizedAt: *redemption.CompletedAt,
|
||||
})
|
||||
}
|
||||
return p.redemptions.CreateCosts(ctx, costs)
|
||||
}
|
||||
|
||||
// replay answers a retry with the redemption it repeats.
|
||||
func (p *VoucherRedemptionProcessor) replay(ctx context.Context, redemption *entities.VoucherRedemption, voucherID uuid.UUID) (*models.VoucherRedeemResult, error) {
|
||||
if redemption.VoucherID != voucherID {
|
||||
return nil, redemptionRejected("this Idempotency-Key was already used to redeem another voucher")
|
||||
}
|
||||
voucher, err := p.vouchers.GetVoucher(ctx, redemption.OrganizationID, redemption.VoucherID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var code *entities.VoucherCode
|
||||
if redemption.VoucherCodeID != nil {
|
||||
if code, err = p.vouchers.GetCode(ctx, *redemption.VoucherCodeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return p.result(ctx, redemption, voucher, code, true)
|
||||
}
|
||||
|
||||
func (p *VoucherRedemptionProcessor) result(ctx context.Context, r *entities.VoucherRedemption, v *entities.Voucher, code *entities.VoucherCode, replayed bool) (*models.VoucherRedeemResult, error) {
|
||||
balances, err := p.spendable.SpendableBalances(ctx, r.CustomerID, p.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := repository.CustomerRedemption{VoucherRedemption: *r, VoucherName: v.Name, VoucherImageURL: v.ImageURL, VoucherType: v.VoucherType, Code: r.ExternalCode}
|
||||
if code != nil {
|
||||
c := code.Code
|
||||
item.Code, item.CodeExpiresAt = &c, code.ExpiresAt
|
||||
}
|
||||
return &models.VoucherRedeemResult{
|
||||
CustomerVoucherRedemption: customerRedemptionModel(item),
|
||||
PointBalance: balances[constants.WalletCurrencyPoint],
|
||||
Replayed: replayed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Catalog returns the vouchers the customer can redeem now, with how many are left.
|
||||
func (p *VoucherRedemptionProcessor) Catalog(ctx context.Context, customerID uuid.UUID) ([]models.CustomerVoucher, error) {
|
||||
customer, err := p.customers.GetCustomer(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vouchers, err := p.vouchers.ListCatalog(ctx, customer.OrganizationID, p.now(), catalogStockModes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]models.CustomerVoucher, 0, len(vouchers))
|
||||
for _, v := range vouchers {
|
||||
if v.StockMode == constants.VoucherStockExternal {
|
||||
if _, ok := p.providerOf(&v.Voucher); !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, models.CustomerVoucher{
|
||||
ID: v.ID, Name: v.Name, Description: v.Description, ImageURL: v.ImageURL, VoucherType: v.VoucherType,
|
||||
FaceValue: v.FaceValue, PointCost: v.PointCost, MaxPerCustomer: v.MaxPerCustomer, ValidUntil: v.ValidUntil,
|
||||
Terms: []byte(v.Terms), Available: v.Available,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListRedemptions returns a page of the customer's redemptions, newest first, with
|
||||
// their codes.
|
||||
func (p *VoucherRedemptionProcessor) ListRedemptions(ctx context.Context, customerID uuid.UUID, page, limit int) (*models.PaginatedResponse[models.CustomerVoucherRedemption], error) {
|
||||
page, limit = enakGamePage(page, limit)
|
||||
rows, total, err := p.redemptions.ListCustomerRedemptions(ctx, customerID, (page-1)*limit, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]models.CustomerVoucherRedemption, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, customerRedemptionModel(row))
|
||||
}
|
||||
return &models.PaginatedResponse[models.CustomerVoucherRedemption]{Data: items, Pagination: enakGamePagination(page, limit, total)}, nil
|
||||
}
|
||||
|
||||
// redeemable says why a voucher cannot be redeemed now, or nil.
|
||||
func (p *VoucherRedemptionProcessor) redeemable(v *entities.Voucher, now time.Time) error {
|
||||
if v.StockMode == constants.VoucherStockExternal {
|
||||
if _, ok := p.providerOf(v); !ok {
|
||||
return redemptionRejected("the voucher is not available yet")
|
||||
}
|
||||
}
|
||||
return redeemableNow(v, now)
|
||||
}
|
||||
|
||||
func redeemableNow(v *entities.Voucher, now time.Time) error {
|
||||
switch {
|
||||
case v.Status != constants.VoucherStatusActive:
|
||||
return redemptionRejected("the voucher is not available")
|
||||
case v.ValidFrom != nil && now.Before(*v.ValidFrom):
|
||||
return redemptionRejected("the voucher cannot be redeemed yet")
|
||||
case v.ValidUntil != nil && !now.Before(*v.ValidUntil):
|
||||
return redemptionRejected("the voucher has ended")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func customerRedemptionModel(r repository.CustomerRedemption) models.CustomerVoucherRedemption {
|
||||
return models.CustomerVoucherRedemption{
|
||||
ID: r.ID, VoucherID: r.VoucherID, VoucherName: r.VoucherName, VoucherImageURL: r.VoucherImageURL,
|
||||
VoucherType: r.VoucherType, Status: r.Status, FaceValue: r.FaceValue, PointCost: r.PointCost,
|
||||
Code: r.Code, CodeExpiresAt: r.CodeExpiresAt, CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user