Files
apskel-pos-backend/internal/repository/voucher_redemption_repository.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

292 lines
12 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
// RedemptionPointSource is EnakPoint a redemption spent, traced back to the ledger row
// that first created it (D5): GAME_REWARD with the budget that paid for it, or EARN,
// ADJUSTMENT, MIGRATION with none.
type RedemptionPointSource struct {
Points int64
SourceType string
BudgetID *uuid.UUID
}
// CustomerRedemption is a redemption as its customer lists it, with the voucher it was
// for and its code.
type CustomerRedemption struct {
entities.VoucherRedemption
VoucherName string
VoucherImageURL *string
VoucherType string
Code *string
CodeExpiresAt *time.Time
}
// VoucherRedemptionRepository stores redemptions and what they cost each budget
// (docs/rfc-enakgame.md §5.7, §7.4, §7.6).
type VoucherRedemptionRepository interface {
// GetByKey returns a customer's redemption made with an Idempotency-Key, or nil.
GetByKey(ctx context.Context, customerID uuid.UUID, idempotencyKey string) (*entities.VoucherRedemption, error)
// CountForCustomer counts a customer's redemptions of a voucher that went through
// or may still: COMPLETED and PENDING.
CountForCustomer(ctx context.Context, customerID, voucherID uuid.UUID) (int64, error)
CreateRedemption(ctx context.Context, redemption *entities.VoucherRedemption) error
// ListCustomerRedemptions returns a page of a customer's redemptions, newest first,
// and the total.
ListCustomerRedemptions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerRedemption, int64, error)
// PointSources follows the lots a debit drew from back to their root lots and
// sums the EnakPoint per source type and budget (§7.6). Exchanges, transfers and
// refunds are walked through, since their lots point at the lots they came from.
PointSources(ctx context.Context, debitTransactionID uuid.UUID) ([]RedemptionPointSource, error)
GetRedemption(ctx context.Context, id uuid.UUID) (*entities.VoucherRedemption, error)
// MarkCompleted settles a PENDING redemption with the provider's voucher, and
// reports false when it was no longer PENDING.
MarkCompleted(ctx context.Context, id uuid.UUID, code, ref *string, at time.Time) (bool, error)
// MarkFailed settles a PENDING redemption as failed, with the refund of its
// EnakPoint, and reports false when it was no longer PENDING.
MarkFailed(ctx context.Context, id, refundTransactionID uuid.UUID, reason string) (bool, error)
// TouchPending counts one more unanswered call to the provider and returns the
// count; 0 when the redemption is no longer PENDING.
TouchPending(ctx context.Context, id uuid.UUID) (int, error)
// ClaimStalePending takes at most limit PENDING redemptions untouched since before,
// oldest first, and touches them, so another instance leaves them alone until they
// go stale again.
ClaimStalePending(ctx context.Context, before time.Time, limit int) ([]entities.VoucherRedemption, error)
CreateCosts(ctx context.Context, costs []entities.VoucherRedemptionCost) error
ListCosts(ctx context.Context, redemptionID uuid.UUID) ([]entities.VoucherRedemptionCost, error)
}
type voucherRedemptionRepository struct {
db *gorm.DB
}
func NewVoucherRedemptionRepository(db *gorm.DB) VoucherRedemptionRepository {
return &voucherRedemptionRepository{db: db}
}
func (r *voucherRedemptionRepository) GetByKey(ctx context.Context, customerID uuid.UUID, idempotencyKey string) (*entities.VoucherRedemption, error) {
var redemption entities.VoucherRedemption
err := DBFromContext(ctx, r.db).WithContext(ctx).
Where("customer_id = ? AND idempotency_key = ?", customerID, idempotencyKey).First(&redemption).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read voucher redemption: %w", err)
}
return &redemption, nil
}
func (r *voucherRedemptionRepository) CountForCustomer(ctx context.Context, customerID, voucherID uuid.UUID) (int64, error) {
var count int64
err := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.VoucherRedemption{}).
Where("customer_id = ? AND voucher_id = ? AND status IN ?", customerID, voucherID,
[]string{constants.VoucherRedemptionCompleted, constants.VoucherRedemptionPending}).
Count(&count).Error
if err != nil {
return 0, fmt.Errorf("failed to count voucher redemptions: %w", err)
}
return count, nil
}
func (r *voucherRedemptionRepository) CreateRedemption(ctx context.Context, redemption *entities.VoucherRedemption) error {
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(redemption).Error; err != nil {
return fmt.Errorf("failed to create voucher redemption: %w", err)
}
return nil
}
func (r *voucherRedemptionRepository) ListCustomerRedemptions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerRedemption, int64, error) {
db := DBFromContext(ctx, r.db).WithContext(ctx)
var total int64
if err := db.Model(&entities.VoucherRedemption{}).Where("customer_id = ?", customerID).Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count voucher redemptions: %w", err)
}
var redemptions []entities.VoucherRedemption
err := db.Where("customer_id = ?", customerID).Order("created_at DESC, id").Offset(offset).Limit(limit).Find(&redemptions).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to list voucher redemptions: %w", err)
}
voucherIDs, codeIDs := []uuid.UUID{}, []uuid.UUID{}
for _, red := range redemptions {
voucherIDs = append(voucherIDs, red.VoucherID)
if red.VoucherCodeID != nil {
codeIDs = append(codeIDs, *red.VoucherCodeID)
}
}
vouchers := map[uuid.UUID]entities.Voucher{}
if len(voucherIDs) > 0 {
var rows []entities.Voucher
if err := db.Where("id IN ?", voucherIDs).Find(&rows).Error; err != nil {
return nil, 0, fmt.Errorf("failed to read redeemed vouchers: %w", err)
}
for _, v := range rows {
vouchers[v.ID] = v
}
}
codes := map[uuid.UUID]entities.VoucherCode{}
if len(codeIDs) > 0 {
var rows []entities.VoucherCode
if err := db.Where("id IN ?", codeIDs).Find(&rows).Error; err != nil {
return nil, 0, fmt.Errorf("failed to read redeemed codes: %w", err)
}
for _, c := range rows {
codes[c.ID] = c
}
}
out := make([]CustomerRedemption, 0, len(redemptions))
for _, red := range redemptions {
v := vouchers[red.VoucherID]
item := CustomerRedemption{VoucherRedemption: red, VoucherName: v.Name, VoucherImageURL: v.ImageURL, VoucherType: v.VoucherType, Code: red.ExternalCode}
if red.VoucherCodeID != nil {
if c, ok := codes[*red.VoucherCodeID]; ok {
code := c.Code
item.Code, item.CodeExpiresAt = &code, c.ExpiresAt
}
}
out = append(out, item)
}
return out, total, nil
}
func (r *voucherRedemptionRepository) PointSources(ctx context.Context, debitTransactionID uuid.UUID) ([]RedemptionPointSource, error) {
var rows []struct {
Points int64
SourceType string
BudgetID *string
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
WITH RECURSIVE chain AS (
SELECT a.lot_id AS spent_lot, a.amount AS points, l.origin_lot_id, l.source_transaction_id
FROM wallet_lot_allocations a
JOIN wallet_lots l ON l.id = a.lot_id
WHERE a.transaction_id = ?
UNION ALL
SELECT c.spent_lot, c.points, p.origin_lot_id, p.source_transaction_id
FROM chain c
JOIN wallet_lots p ON p.id = c.origin_lot_id
)
SELECT SUM(c.points) AS points, t.type AS source_type, gsr.budget_id::text AS budget_id
FROM chain c
JOIN wallet_transactions t ON t.id = c.source_transaction_id
LEFT JOIN game_session_rewards gsr ON gsr.wallet_transaction_id = t.id
WHERE c.origin_lot_id IS NULL
GROUP BY t.type, gsr.budget_id
ORDER BY t.type, gsr.budget_id`, debitTransactionID).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to trace redeemed EnakPoint: %w", err)
}
out := make([]RedemptionPointSource, 0, len(rows))
for _, row := range rows {
s := RedemptionPointSource{Points: row.Points, SourceType: row.SourceType}
if row.BudgetID != nil {
id, err := uuid.Parse(*row.BudgetID)
if err != nil {
return nil, fmt.Errorf("bad budget id %q: %w", *row.BudgetID, err)
}
s.BudgetID = &id
}
out = append(out, s)
}
return out, nil
}
func (r *voucherRedemptionRepository) CreateCosts(ctx context.Context, costs []entities.VoucherRedemptionCost) error {
if len(costs) == 0 {
return nil
}
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(&costs).Error; err != nil {
return fmt.Errorf("failed to record redemption costs: %w", err)
}
return nil
}
func (r *voucherRedemptionRepository) ListCosts(ctx context.Context, redemptionID uuid.UUID) ([]entities.VoucherRedemptionCost, error) {
var costs []entities.VoucherRedemptionCost
err := DBFromContext(ctx, r.db).WithContext(ctx).Where("redemption_id = ?", redemptionID).
Order("cost DESC, source_type").Find(&costs).Error
if err != nil {
return nil, fmt.Errorf("failed to list redemption costs: %w", err)
}
return costs, nil
}
func (r *voucherRedemptionRepository) GetRedemption(ctx context.Context, id uuid.UUID) (*entities.VoucherRedemption, error) {
var redemption entities.VoucherRedemption
if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id = ?", id).First(&redemption).Error; err != nil {
return nil, fmt.Errorf("failed to read voucher redemption: %w", err)
}
return &redemption, nil
}
func (r *voucherRedemptionRepository) MarkCompleted(ctx context.Context, id uuid.UUID, code, ref *string, at time.Time) (bool, error) {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE voucher_redemptions SET status = ?, external_code = ?, external_ref = ?, completed_at = ?,
attempts = attempts + 1, updated_at = NOW()
WHERE id = ? AND status = ?`,
constants.VoucherRedemptionCompleted, code, ref, at, id, constants.VoucherRedemptionPending)
if result.Error != nil {
return false, fmt.Errorf("failed to complete voucher redemption: %w", result.Error)
}
return result.RowsAffected == 1, nil
}
func (r *voucherRedemptionRepository) MarkFailed(ctx context.Context, id, refundTransactionID uuid.UUID, reason string) (bool, error) {
if len(reason) > 255 {
reason = reason[:255]
}
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE voucher_redemptions SET status = ?, refund_transaction_id = ?, failure_reason = ?,
attempts = attempts + 1, updated_at = NOW()
WHERE id = ? AND status = ?`,
constants.VoucherRedemptionFailed, refundTransactionID, reason, id, constants.VoucherRedemptionPending)
if result.Error != nil {
return false, fmt.Errorf("failed to fail voucher redemption: %w", result.Error)
}
return result.RowsAffected == 1, nil
}
func (r *voucherRedemptionRepository) TouchPending(ctx context.Context, id uuid.UUID) (int, error) {
var attempts []int
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
UPDATE voucher_redemptions SET attempts = attempts + 1, updated_at = NOW()
WHERE id = ? AND status = ? RETURNING attempts`, id, constants.VoucherRedemptionPending).Scan(&attempts).Error
if err != nil {
return 0, fmt.Errorf("failed to touch voucher redemption: %w", err)
}
if len(attempts) == 0 {
return 0, nil
}
return attempts[0], nil
}
func (r *voucherRedemptionRepository) ClaimStalePending(ctx context.Context, before time.Time, limit int) ([]entities.VoucherRedemption, error) {
var claimed []entities.VoucherRedemption
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
UPDATE voucher_redemptions SET updated_at = NOW()
WHERE id IN (
SELECT id FROM voucher_redemptions
WHERE status = ? AND updated_at <= ?
ORDER BY updated_at, id
LIMIT ?
FOR UPDATE SKIP LOCKED)
RETURNING *`, constants.VoucherRedemptionPending, before, limit).Scan(&claimed).Error
if err != nil {
return nil, fmt.Errorf("failed to claim pending voucher redemptions: %w", err)
}
return claimed, nil
}