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

342 lines
13 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
// ErrVoucherNotFound means no voucher with that id in the organization.
var ErrVoucherNotFound = errors.New("enakgame: voucher not found")
// voucherCodeImportBatch is how many codes one insert writes.
const voucherCodeImportBatch = 1000
// VoucherFilter selects an organization's vouchers.
type VoucherFilter struct {
OrganizationID uuid.UUID
// Empty for every status.
Statuses []string
Search string
Offset int
Limit int
}
// VoucherCodeImport is one code to add to a pool.
type VoucherCodeImport struct {
Code string
ExpiresAt *time.Time
}
// CatalogVoucher is a voucher a customer can redeem now, with how many are left. Nil
// Available means no counted stock.
type CatalogVoucher struct {
entities.Voucher
Available *int64
}
// VoucherRepository stores EnakGame vouchers and their codes (docs/rfc-enakgame.md
// §5.7), always scoped to an organization.
type VoucherRepository interface {
CreateVoucher(ctx context.Context, voucher *entities.Voucher) error
GetVoucher(ctx context.Context, organizationID, id uuid.UUID) (*entities.Voucher, error)
// LockVoucher is GetVoucher with the row locked until the transaction ends.
LockVoucher(ctx context.Context, organizationID, id uuid.UUID) (*entities.Voucher, error)
ListVouchers(ctx context.Context, filter VoucherFilter) ([]entities.Voucher, int64, error)
// UpdateVoucher stores everything but the organization, the stock mode and the
// status.
UpdateVoucher(ctx context.Context, voucher *entities.Voucher) error
SetVoucherStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error
// TakeStock takes one from a STATIC voucher's stock, and reports false when none is
// left.
TakeStock(ctx context.Context, voucherID uuid.UUID) (bool, error)
// ImportCodes adds codes to a pool and returns those it added; a code the pool
// already holds is skipped.
ImportCodes(ctx context.Context, voucherID uuid.UUID, codes []VoucherCodeImport) ([]string, error)
// CountCodes returns how many codes of a pool are in each status.
CountCodes(ctx context.Context, voucherID uuid.UUID) (map[string]int64, error)
// ListCodes returns a page of a pool's codes, oldest first, and the total.
ListCodes(ctx context.Context, voucherID uuid.UUID, status string, offset, limit int) ([]entities.VoucherCode, int64, error)
GetCode(ctx context.Context, id uuid.UUID) (*entities.VoucherCode, error)
// ClaimCode gives the oldest available, unexpired code of a pool to a redemption,
// skipping codes other redemptions hold at the moment, so two redemptions at once
// get different codes. It returns nil when none is left.
ClaimCode(ctx context.Context, voucherID, redemptionID uuid.UUID, now time.Time) (*entities.VoucherCode, error)
// ExpireCodes moves at most limit available codes past their expiry to EXPIRED
// and returns how many.
ExpireCodes(ctx context.Context, now time.Time, limit int) (int64, error)
// ListCatalog returns the vouchers a customer of the organization can redeem now:
// ACTIVE, within their dates, in a stock mode that can be redeemed.
ListCatalog(ctx context.Context, organizationID uuid.UUID, now time.Time, stockModes []string) ([]CatalogVoucher, error)
}
type voucherRepository struct {
db *gorm.DB
}
func NewVoucherRepository(db *gorm.DB) VoucherRepository {
return &voucherRepository{db: db}
}
func (r *voucherRepository) CreateVoucher(ctx context.Context, voucher *entities.Voucher) error {
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(voucher).Error; err != nil {
return fmt.Errorf("failed to create voucher: %w", err)
}
return nil
}
func (r *voucherRepository) getVoucher(ctx context.Context, organizationID, id uuid.UUID, lock bool) (*entities.Voucher, error) {
q := DBFromContext(ctx, r.db).WithContext(ctx).Where("organization_id = ? AND id = ?", organizationID, id)
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
var voucher entities.Voucher
if err := q.First(&voucher).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrVoucherNotFound
}
return nil, fmt.Errorf("failed to read voucher: %w", err)
}
return &voucher, nil
}
func (r *voucherRepository) GetVoucher(ctx context.Context, organizationID, id uuid.UUID) (*entities.Voucher, error) {
return r.getVoucher(ctx, organizationID, id, false)
}
func (r *voucherRepository) LockVoucher(ctx context.Context, organizationID, id uuid.UUID) (*entities.Voucher, error) {
return r.getVoucher(ctx, organizationID, id, true)
}
func (r *voucherRepository) ListVouchers(ctx context.Context, filter VoucherFilter) ([]entities.Voucher, int64, error) {
q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.Voucher{}).Where("organization_id = ?", filter.OrganizationID)
if len(filter.Statuses) > 0 {
q = q.Where("status IN ?", filter.Statuses)
}
if filter.Search != "" {
q = q.Where("name ILIKE ?", "%"+filter.Search+"%")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count vouchers: %w", err)
}
var vouchers []entities.Voucher
if err := q.Order("created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&vouchers).Error; err != nil {
return nil, 0, fmt.Errorf("failed to list vouchers: %w", err)
}
return vouchers, total, nil
}
func (r *voucherRepository) UpdateVoucher(ctx context.Context, v *entities.Voucher) error {
terms := v.Terms
if len(terms) == 0 {
terms = entities.JSONDocument(`{}`)
}
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE vouchers SET name = ?, description = ?, image_url = ?, voucher_type = ?, face_value = ?,
point_cost = ?, business_cost = ?, stock = ?, provider = ?, provider_ref = ?, max_per_customer = ?,
valid_from = ?, valid_until = ?, terms = ?::jsonb, updated_at = NOW()
WHERE organization_id = ? AND id = ?`,
v.Name, v.Description, v.ImageURL, v.VoucherType, v.FaceValue, v.PointCost, v.BusinessCost, v.Stock,
v.Provider, v.ProviderRef, v.MaxPerCustomer, v.ValidFrom, v.ValidUntil, terms, v.OrganizationID, v.ID)
if result.Error != nil {
return fmt.Errorf("failed to update voucher: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrVoucherNotFound
}
return nil
}
func (r *voucherRepository) SetVoucherStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE vouchers SET status = ?, updated_at = NOW() WHERE organization_id = ? AND id = ?`, status, organizationID, id)
if result.Error != nil {
return fmt.Errorf("failed to change voucher status: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrVoucherNotFound
}
return nil
}
func (r *voucherRepository) TakeStock(ctx context.Context, voucherID uuid.UUID) (bool, error) {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE vouchers SET stock = stock - 1, updated_at = NOW() WHERE id = ? AND stock > 0`, voucherID)
if result.Error != nil {
return false, fmt.Errorf("failed to take voucher stock: %w", result.Error)
}
return result.RowsAffected == 1, nil
}
func (r *voucherRepository) ImportCodes(ctx context.Context, voucherID uuid.UUID, codes []VoucherCodeImport) ([]string, error) {
db := DBFromContext(ctx, r.db).WithContext(ctx)
var added []string
for start := 0; start < len(codes); start += voucherCodeImportBatch {
end := start + voucherCodeImportBatch
if end > len(codes) {
end = len(codes)
}
rows := make([]entities.VoucherCode, 0, end-start)
for _, c := range codes[start:end] {
rows = append(rows, entities.VoucherCode{
ID: uuid.New(), VoucherID: voucherID, Code: c.Code, Status: constants.VoucherCodeAvailable, ExpiresAt: c.ExpiresAt,
})
}
err := db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "voucher_id"}, {Name: "code"}}, DoNothing: true}).
Create(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to import voucher codes: %w", err)
}
// A skipped code kept the row of the pool's earlier copy, so only the new ids
// are in the table.
ids := make([]uuid.UUID, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
var created []string
if err := db.Model(&entities.VoucherCode{}).Where("id IN ?", ids).Order("code").Pluck("code", &created).Error; err != nil {
return nil, fmt.Errorf("failed to read imported voucher codes: %w", err)
}
added = append(added, created...)
}
return added, nil
}
func (r *voucherRepository) CountCodes(ctx context.Context, voucherID uuid.UUID) (map[string]int64, error) {
var rows []struct {
Status string
Count int64
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT status, COUNT(*) AS count FROM voucher_codes WHERE voucher_id = ? GROUP BY status`, voucherID).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to count voucher codes: %w", err)
}
counts := map[string]int64{}
for _, row := range rows {
counts[row.Status] = row.Count
}
return counts, nil
}
func (r *voucherRepository) ListCodes(ctx context.Context, voucherID uuid.UUID, status string, offset, limit int) ([]entities.VoucherCode, int64, error) {
q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.VoucherCode{}).Where("voucher_id = ?", voucherID)
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count voucher codes: %w", err)
}
var codes []entities.VoucherCode
if err := q.Order("created_at, code").Offset(offset).Limit(limit).Find(&codes).Error; err != nil {
return nil, 0, fmt.Errorf("failed to list voucher codes: %w", err)
}
return codes, total, nil
}
func (r *voucherRepository) GetCode(ctx context.Context, id uuid.UUID) (*entities.VoucherCode, error) {
var code entities.VoucherCode
if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id = ?", id).First(&code).Error; err != nil {
return nil, fmt.Errorf("failed to read voucher code: %w", err)
}
return &code, nil
}
func (r *voucherRepository) ClaimCode(ctx context.Context, voucherID, redemptionID uuid.UUID, now time.Time) (*entities.VoucherCode, error) {
var claimed []entities.VoucherCode
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
UPDATE voucher_codes SET status = ?, redemption_id = ?, updated_at = NOW()
WHERE id = (
SELECT id FROM voucher_codes
WHERE voucher_id = ? AND status = ? AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at, id
LIMIT 1
FOR UPDATE SKIP LOCKED)
RETURNING *`,
constants.VoucherCodeRedeemed, redemptionID, voucherID, constants.VoucherCodeAvailable, now).Scan(&claimed).Error
if err != nil {
return nil, fmt.Errorf("failed to claim voucher code: %w", err)
}
if len(claimed) == 0 {
return nil, nil
}
return &claimed[0], nil
}
func (r *voucherRepository) ExpireCodes(ctx context.Context, now time.Time, limit int) (int64, error) {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE voucher_codes SET status = ?, updated_at = NOW()
WHERE id IN (
SELECT id FROM voucher_codes
WHERE status = ? AND expires_at IS NOT NULL AND expires_at <= ?
ORDER BY expires_at
LIMIT ?
FOR UPDATE SKIP LOCKED)`,
constants.VoucherCodeExpired, constants.VoucherCodeAvailable, now, limit)
if result.Error != nil {
return 0, fmt.Errorf("failed to expire voucher codes: %w", result.Error)
}
return result.RowsAffected, nil
}
func (r *voucherRepository) ListCatalog(ctx context.Context, organizationID uuid.UUID, now time.Time, stockModes []string) ([]CatalogVoucher, error) {
db := DBFromContext(ctx, r.db).WithContext(ctx)
var vouchers []entities.Voucher
err := db.Where(`organization_id = ? AND status = ? AND stock_mode IN ?
AND (valid_from IS NULL OR valid_from <= ?) AND (valid_until IS NULL OR valid_until > ?)`,
organizationID, constants.VoucherStatusActive, stockModes, now, now).
Order("point_cost, name, id").Find(&vouchers).Error
if err != nil {
return nil, fmt.Errorf("failed to list voucher catalog: %w", err)
}
var pools []uuid.UUID
for _, v := range vouchers {
if v.StockMode == constants.VoucherStockCodePool {
pools = append(pools, v.ID)
}
}
available := map[uuid.UUID]int64{}
if len(pools) > 0 {
var rows []struct {
VoucherID string
Count int64
}
err := db.Raw(`
SELECT voucher_id::text AS voucher_id, COUNT(*) AS count FROM voucher_codes
WHERE voucher_id IN ? AND status = ? AND (expires_at IS NULL OR expires_at > ?)
GROUP BY voucher_id`, pools, constants.VoucherCodeAvailable, now).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to count available voucher codes: %w", err)
}
for _, row := range rows {
id, _ := uuid.Parse(row.VoucherID)
available[id] = row.Count
}
}
out := make([]CatalogVoucher, 0, len(vouchers))
for _, v := range vouchers {
c := CatalogVoucher{Voucher: v}
switch v.StockMode {
case constants.VoucherStockStatic:
c.Available = v.Stock
case constants.VoucherStockCodePool:
n := available[v.ID]
c.Available = &n
}
out = append(out, c)
}
return out, nil
}