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>
292 lines
12 KiB
Go
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
|
|
}
|