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

220 lines
8.5 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/entities"
)
// ErrLoyaltyOutletNotFound means the outlet does not exist or belongs to another
// organization.
var ErrLoyaltyOutletNotFound = errors.New("loyalty settings: outlet not found")
// LoyaltySettingChangeFilter narrows the loyalty settings history.
type LoyaltySettingChangeFilter struct {
OrganizationID uuid.UUID
// Nil for the whole organization, outlets included.
OutletID *uuid.UUID
Offset int
Limit int
}
// LoyaltySettingsRepository stores the raw loyalty settings (the loyalty.* and
// enakgame.* keys of outlet_settings and organization_settings) and their history
// (docs/prd-point-coin.md F1, F2). The loyalty settings processor turns them into
// typed values and is the only writer.
type LoyaltySettingsRepository interface {
// GetOutletValues returns the stored loyalty keys of an outlet. A key with no row,
// or a NULL value, is absent.
GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error)
GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error)
// OutletOrganizationID returns ErrLoyaltyOutletNotFound for an unknown outlet.
OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error)
// LockScope serializes writers of one outlet's or one organization's settings
// until the transaction ends, so two saves at once cannot both compute their
// changes from the same old values.
LockScope(ctx context.Context, scopeID uuid.UUID) error
// SetOutletValue stores a value, or removes the key when value is nil so it falls
// back to its default.
SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error
SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error
InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error
// ListChanges returns a page of history, newest first, and the total count.
ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error)
// UndatedBalance counts an organization's lots of one currency that still hold
// something and have no expiry, and what they hold.
UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (lots, amount int64, err error)
// DateUndatedLots gives those lots an expiry, when expiry is turned on (F12).
DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (lots, amount int64, err error)
}
type loyaltySettingsRepository struct {
db *gorm.DB
}
func NewLoyaltySettingsRepository(db *gorm.DB) LoyaltySettingsRepository {
return &loyaltySettingsRepository{db: db}
}
// requireTx returns the caller's transaction. Writes must run in one, together with
// the history rows they produce.
func (r *loyaltySettingsRepository) requireTx(ctx context.Context) (*gorm.DB, error) {
if tx, ok := ctx.Value(txKey).(*gorm.DB); ok && tx != nil {
return tx.WithContext(ctx), nil
}
return nil, errors.New("loyalty settings: write must run inside a transaction")
}
func (r *loyaltySettingsRepository) values(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID) (map[string]string, error) {
var rows []struct {
Key string
Value string
}
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table(table).
Select("key, value").
Where(scopeColumn+" = ? AND (key LIKE 'loyalty.%' OR key LIKE 'enakgame.%') AND value IS NOT NULL", scopeID).
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", table, err)
}
values := make(map[string]string, len(rows))
for _, row := range rows {
values[row.Key] = row.Value
}
return values, nil
}
func (r *loyaltySettingsRepository) GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error) {
return r.values(ctx, "outlet_settings", "outlet_id", outletID)
}
func (r *loyaltySettingsRepository) GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error) {
return r.values(ctx, "organization_settings", "organization_id", organizationID)
}
func (r *loyaltySettingsRepository) OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error) {
var ids []string
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table("outlets").
Where("id = ?", outletID).
Limit(1).
Pluck("organization_id::text", &ids).Error
if err != nil {
return uuid.Nil, fmt.Errorf("failed to get outlet organization: %w", err)
}
if len(ids) == 0 {
return uuid.Nil, ErrLoyaltyOutletNotFound
}
return uuid.Parse(ids[0])
}
func (r *loyaltySettingsRepository) LockScope(ctx context.Context, scopeID uuid.UUID) error {
db, err := r.requireTx(ctx)
if err != nil {
return err
}
return db.Exec(`SELECT pg_advisory_xact_lock(hashtextextended(?, 0))`, "loyalty-settings:"+scopeID.String()).Error
}
func (r *loyaltySettingsRepository) SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error {
return r.setValue(ctx, "outlet_settings", "outlet_id", outletID, key, value)
}
func (r *loyaltySettingsRepository) SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error {
return r.setValue(ctx, "organization_settings", "organization_id", organizationID, key, value)
}
// setValue upserts or deletes one key. table and scopeColumn are never user input.
func (r *loyaltySettingsRepository) setValue(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID, key string, value *string) error {
db, err := r.requireTx(ctx)
if err != nil {
return err
}
if value == nil {
err = db.Exec(`DELETE FROM `+table+` WHERE `+scopeColumn+` = ? AND key = ?`, scopeID, key).Error
} else {
err = db.Exec(`INSERT INTO `+table+` (`+scopeColumn+`, key, value) VALUES (?, ?, ?)
ON CONFLICT (`+scopeColumn+`, key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
scopeID, key, *value).Error
}
if err != nil {
return fmt.Errorf("failed to store %s in %s: %w", key, table, err)
}
return nil
}
func (r *loyaltySettingsRepository) InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error {
if len(changes) == 0 {
return nil
}
db, err := r.requireTx(ctx)
if err != nil {
return err
}
return db.Create(&changes).Error
}
func (r *loyaltySettingsRepository) ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) {
query := DBFromContext(ctx, r.db).WithContext(ctx).
Model(&entities.LoyaltySettingChange{}).
Where("organization_id = ?", filter.OrganizationID)
if filter.OutletID != nil {
query = query.Where("outlet_id = ?", *filter.OutletID)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count loyalty setting changes: %w", err)
}
var changes []entities.LoyaltySettingChange
err := query.Order("created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&changes).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to list loyalty setting changes: %w", err)
}
return changes, total, nil
}
func (r *loyaltySettingsRepository) UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (int64, int64, error) {
var row struct{ Lots, Amount int64 }
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT COUNT(*) AS lots, COALESCE(SUM(remaining_amount), 0) AS amount
FROM wallet_lots
WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0`,
organizationID, currency).Scan(&row).Error
if err != nil {
return 0, 0, fmt.Errorf("failed to sum undated wallet lots: %w", err)
}
return row.Lots, row.Amount, nil
}
func (r *loyaltySettingsRepository) DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) {
db, err := r.requireTx(ctx)
if err != nil {
return 0, 0, err
}
var rows []struct{ RemainingAmount int64 }
// Only lots that still hold something: an empty lot never expires anything, and
// leaving it undated keeps the history as it was.
err = db.Raw(`
UPDATE wallet_lots SET expires_at = ?
WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0
RETURNING remaining_amount`,
expiresAt, organizationID, currency).Scan(&rows).Error
if err != nil {
return 0, 0, fmt.Errorf("failed to date wallet lots: %w", err)
}
var amount int64
for _, row := range rows {
amount += row.RemainingAmount
}
return int64(len(rows)), amount, nil
}