From 798a36bd6cea58c54f9edf6136eb544ad7e60b68 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 7 Oct 2026 20:53:14 +0700 Subject: [PATCH] feat(enakgame): game sessions, rewards, vouchers, budgets and events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/api-enakpoint.md | 3 +- docs/backoffice-enakpoint.md | 5 +- docs/integration-enakpoint.md | 3 +- go.mod | 2 +- internal/app/app.go | 66 ++ internal/constants/enakgame.go | 146 +++ internal/constants/error.go | 1 + internal/constants/loyalty.go | 7 + internal/constants/wallet.go | 6 + internal/contract/enakgame_contract.go | 15 + internal/entities/audit_log.go | 30 + internal/entities/enakgame.go | 197 ++++ internal/entities/game.go | 26 +- internal/entities/voucher.go | 88 ++ internal/handler/customer_wallet_db_test.go | 2 +- internal/handler/enakgame_admin_handler.go | 410 ++++++++ internal/handler/enakgame_customer_handler.go | 148 +++ internal/handler/enakgame_db_test.go | 476 +++++++++ .../handler/loyalty_settings_org_db_test.go | 14 + internal/models/enakgame.go | 435 ++++++++ internal/models/loyalty.go | 10 + internal/processor/audit_logger.go | 103 ++ internal/processor/audit_logger_test.go | 129 +++ internal/processor/customer_pin_processor.go | 2 + .../processor/customer_pin_processor_test.go | 32 + .../processor/enakgame_admin_processor.go | 462 +++++++++ internal/processor/enakgame_db_test.go | 974 ++++++++++++++++++ .../processor/game_budget_metrics_db_test.go | 95 ++ .../game_budget_metrics_processor.go | 155 +++ .../game_budget_metrics_processor_test.go | 61 ++ internal/processor/game_budget_processor.go | 257 +++++ internal/processor/game_event_db_test.go | 289 ++++++ internal/processor/game_event_processor.go | 306 ++++++ .../processor/game_play_processor_db_test.go | 3 +- internal/processor/game_session_complete.go | 369 +++++++ internal/processor/game_session_processor.go | 444 ++++++++ internal/processor/game_session_reward.go | 222 ++++ .../processor/game_session_reward_test.go | 65 ++ .../processor/loyalty_settings_processor.go | 5 +- .../loyalty_settings_processor_test.go | 9 +- internal/processor/result_validator.go | 41 + internal/processor/result_validator_test.go | 46 + internal/processor/reward_calculator.go | 320 ++++++ internal/processor/reward_calculator_test.go | 146 +++ internal/processor/voucher_admin_processor.go | 422 ++++++++ internal/processor/voucher_cost.go | 43 + internal/processor/voucher_cost_test.go | 52 + internal/processor/voucher_db_test.go | 477 +++++++++ .../processor/voucher_external_db_test.go | 214 ++++ internal/processor/voucher_provider.go | 76 ++ internal/processor/voucher_provider_test.go | 31 + .../processor/voucher_redemption_external.go | 196 ++++ .../processor/voucher_redemption_processor.go | 343 ++++++ .../wallet_enakgame_types_db_test.go | 133 +++ .../wallet_exchange_processor_test.go | 7 +- internal/processor/wallet_processor.go | 46 +- internal/processor/wallet_processor_test.go | 94 ++ internal/repository/audit_log_repository.go | 124 +++ internal/repository/enakgame_repository.go | 237 +++++ .../repository/enakgame_repository_test.go | 471 +++++++++ .../game_budget_metrics_repository.go | 95 ++ internal/repository/game_budget_repository.go | 233 +++++ internal/repository/game_event_repository.go | 217 ++++ .../game_reward_counter_repository.go | 133 +++ .../game_reward_counter_repository_test.go | 110 ++ .../repository/game_session_repository.go | 239 +++++ .../repository/loyalty_settings_repository.go | 6 +- internal/repository/pg_errors.go | 25 + .../voucher_redemption_repository.go | 291 ++++++ internal/repository/voucher_repository.go | 341 ++++++ .../repository/voucher_repository_test.go | 70 ++ internal/router/router.go | 56 +- internal/service/enakgame_jobs.go | 165 +++ internal/service/enakgame_service.go | 395 +++++++ .../000103_extend_games_for_enakgame.down.sql | 19 + .../000103_extend_games_for_enakgame.up.sql | 42 + .../000104_create_enakgame_tables.down.sql | 4 + .../000104_create_enakgame_tables.up.sql | 120 +++ .../000105_add_enakgame_wallet_types.down.sql | 16 + .../000105_add_enakgame_wallet_types.up.sql | 19 + migrations/000106_create_audit_logs.down.sql | 1 + migrations/000106_create_audit_logs.up.sql | 25 + ...00107_create_game_reward_counters.down.sql | 1 + .../000107_create_game_reward_counters.up.sql | 16 + .../000108_create_voucher_tables.down.sql | 4 + .../000108_create_voucher_tables.up.sql | 123 +++ .../000109_index_wallet_lots_origin.down.sql | 1 + .../000109_index_wallet_lots_origin.up.sql | 4 + .../000110_index_wallet_lots_source.down.sql | 1 + .../000110_index_wallet_lots_source.up.sql | 3 + migrations/000111_create_game_events.down.sql | 2 + migrations/000111_create_game_events.up.sql | 47 + 92 files changed, 12392 insertions(+), 23 deletions(-) create mode 100644 internal/constants/enakgame.go create mode 100644 internal/contract/enakgame_contract.go create mode 100644 internal/entities/audit_log.go create mode 100644 internal/entities/enakgame.go create mode 100644 internal/entities/voucher.go create mode 100644 internal/handler/enakgame_admin_handler.go create mode 100644 internal/handler/enakgame_customer_handler.go create mode 100644 internal/handler/enakgame_db_test.go create mode 100644 internal/models/enakgame.go create mode 100644 internal/processor/audit_logger.go create mode 100644 internal/processor/audit_logger_test.go create mode 100644 internal/processor/enakgame_admin_processor.go create mode 100644 internal/processor/enakgame_db_test.go create mode 100644 internal/processor/game_budget_metrics_db_test.go create mode 100644 internal/processor/game_budget_metrics_processor.go create mode 100644 internal/processor/game_budget_metrics_processor_test.go create mode 100644 internal/processor/game_budget_processor.go create mode 100644 internal/processor/game_event_db_test.go create mode 100644 internal/processor/game_event_processor.go create mode 100644 internal/processor/game_session_complete.go create mode 100644 internal/processor/game_session_processor.go create mode 100644 internal/processor/game_session_reward.go create mode 100644 internal/processor/game_session_reward_test.go create mode 100644 internal/processor/result_validator.go create mode 100644 internal/processor/result_validator_test.go create mode 100644 internal/processor/reward_calculator.go create mode 100644 internal/processor/reward_calculator_test.go create mode 100644 internal/processor/voucher_admin_processor.go create mode 100644 internal/processor/voucher_cost.go create mode 100644 internal/processor/voucher_cost_test.go create mode 100644 internal/processor/voucher_db_test.go create mode 100644 internal/processor/voucher_external_db_test.go create mode 100644 internal/processor/voucher_provider.go create mode 100644 internal/processor/voucher_provider_test.go create mode 100644 internal/processor/voucher_redemption_external.go create mode 100644 internal/processor/voucher_redemption_processor.go create mode 100644 internal/processor/wallet_enakgame_types_db_test.go create mode 100644 internal/repository/audit_log_repository.go create mode 100644 internal/repository/enakgame_repository.go create mode 100644 internal/repository/enakgame_repository_test.go create mode 100644 internal/repository/game_budget_metrics_repository.go create mode 100644 internal/repository/game_budget_repository.go create mode 100644 internal/repository/game_event_repository.go create mode 100644 internal/repository/game_reward_counter_repository.go create mode 100644 internal/repository/game_reward_counter_repository_test.go create mode 100644 internal/repository/game_session_repository.go create mode 100644 internal/repository/pg_errors.go create mode 100644 internal/repository/voucher_redemption_repository.go create mode 100644 internal/repository/voucher_repository.go create mode 100644 internal/repository/voucher_repository_test.go create mode 100644 internal/service/enakgame_jobs.go create mode 100644 internal/service/enakgame_service.go create mode 100644 migrations/000103_extend_games_for_enakgame.down.sql create mode 100644 migrations/000103_extend_games_for_enakgame.up.sql create mode 100644 migrations/000104_create_enakgame_tables.down.sql create mode 100644 migrations/000104_create_enakgame_tables.up.sql create mode 100644 migrations/000105_add_enakgame_wallet_types.down.sql create mode 100644 migrations/000105_add_enakgame_wallet_types.up.sql create mode 100644 migrations/000106_create_audit_logs.down.sql create mode 100644 migrations/000106_create_audit_logs.up.sql create mode 100644 migrations/000107_create_game_reward_counters.down.sql create mode 100644 migrations/000107_create_game_reward_counters.up.sql create mode 100644 migrations/000108_create_voucher_tables.down.sql create mode 100644 migrations/000108_create_voucher_tables.up.sql create mode 100644 migrations/000109_index_wallet_lots_origin.down.sql create mode 100644 migrations/000109_index_wallet_lots_origin.up.sql create mode 100644 migrations/000110_index_wallet_lots_source.down.sql create mode 100644 migrations/000110_index_wallet_lots_source.up.sql create mode 100644 migrations/000111_create_game_events.down.sql create mode 100644 migrations/000111_create_game_events.up.sql diff --git a/docs/api-enakpoint.md b/docs/api-enakpoint.md index 8173218..9b93c95 100644 --- a/docs/api-enakpoint.md +++ b/docs/api-enakpoint.md @@ -273,7 +273,8 @@ Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (defau "end_of_month": false, "reminder_days": 7 }, - "coin_expiry": { "…": "sama dengan point_expiry" } + "coin_expiry": { "…": "sama dengan point_expiry" }, + "enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 } } ``` diff --git a/docs/backoffice-enakpoint.md b/docs/backoffice-enakpoint.md index ae256da..cc78597 100644 --- a/docs/backoffice-enakpoint.md +++ b/docs/backoffice-enakpoint.md @@ -67,7 +67,8 @@ Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa berlaku s "exchange": { "coin_amount": 1, "point_amount": 1 }, "transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null }, "point_expiry": { "…": "lihat bagian kedaluwarsa" }, - "coin_expiry": { "…": "lihat bagian kedaluwarsa" } + "coin_expiry": { "…": "lihat bagian kedaluwarsa" }, + "enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 } } ``` @@ -79,6 +80,8 @@ Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa berlaku s | `transfer.min_amount` | Minimal per transfer | 1 | ≥ 1 | | `transfer.max_per_transaction` | Maksimal per transfer | kosong = tanpa batas | ≥ 1 | | `transfer.daily_limit` | Batas harian per customer | kosong = tanpa batas | ≥ 1, dihitung per currency, reset tengah malam WIB | +| `enakgame.user_daily_limit` | Maksimal EnakCoin dari EnakGame per customer per hari | 0 = tanpa batas | ≥ 0, reset tengah malam WIB | +| `enakgame.global_daily_limit` | Maksimal EnakCoin dari EnakGame seluruh organisasi per hari | 0 = tanpa batas | ≥ 0, reset tengah malam WIB | ### Alur simpan diff --git a/docs/integration-enakpoint.md b/docs/integration-enakpoint.md index c533fa6..7e51933 100644 --- a/docs/integration-enakpoint.md +++ b/docs/integration-enakpoint.md @@ -467,7 +467,8 @@ tanpa menyimpan) "end_of_month": false, "reminder_days": 7 }, - "coin_expiry": { … sama … } + "coin_expiry": { … sama … }, + "enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 } } ``` diff --git a/go.mod b/go.mod index fa0aba6..cc9d532 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,6 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.3.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect @@ -110,6 +109,7 @@ require ( github.com/aws/aws-sdk-go v1.55.7 github.com/boombuler/barcode v1.1.0 github.com/golang-jwt/jwt/v5 v5.2.3 + github.com/jackc/pgx/v5 v5.3.0 github.com/redis/go-redis/v9 v9.19.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 diff --git a/internal/app/app.go b/internal/app/app.go index ece9b5e..36fd6fc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -34,6 +34,10 @@ type App struct { walletRecon *service.WalletReconciliationJob earningRetry *service.EarningBackfillJob walletExpiry *service.WalletExpiryJob + gameSessions *service.GameSessionJob + gameBudgets *service.GameBudgetPeriodJob + voucherCodes *service.VoucherCodeExpiryJob + voucherRecover *service.VoucherRedemptionRecoveryJob } func NewApp(db *gorm.DB, redisClient *redis.Client) *App { @@ -67,6 +71,14 @@ func (a *App) Initialize(cfg *config.Config) error { // Expires balances whose time is up and reminds customers before (docs/prd-point-coin.md F12) a.walletExpiry = service.NewWalletExpiryJob(processor.NewWalletExpiryProcessor( repository.NewWalletExpiryRepository(a.db), processors.loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, processors.customerDeviceProcessor)) + // Refunds or expires EnakGame sessions left open (docs/rfc-enakgame.md §7.3) + a.gameSessions = service.NewGameSessionJob(processors.gameSessionProcessor) + // Opens next month's global EnakGame budget (docs/rfc-enakgame.md §12) + a.gameBudgets = service.NewGameBudgetPeriodJob(processors.gameBudgetProcessor) + // Expires voucher codes past their date (docs/rfc-enakgame.md §12) + a.voucherCodes = service.NewVoucherCodeExpiryJob(processors.voucherAdminProcessor) + // Settles EXTERNAL voucher redemptions left PENDING (docs/rfc-enakgame.md §7.5) + a.voucherRecover = service.NewVoucherRedemptionRecoveryJob(processors.voucherRedemptionProcessor) services := a.initServices(processors, repos, cfg) validators := a.initValidators() @@ -165,6 +177,8 @@ func (a *App) Initialize(cfg *config.Config) error { services.customerDeviceService, services.customerOutletService, services.customerOrderService, + services.enakGameAdminService, + services.enakGameCustomerService, a.redisClient, ) @@ -185,6 +199,18 @@ func (a *App) Start(port string) error { if a.walletExpiry != nil { a.walletExpiry.Start(15 * time.Minute) } + if a.gameSessions != nil { + a.gameSessions.Start(time.Minute) + } + if a.gameBudgets != nil { + a.gameBudgets.Start(24 * time.Hour) + } + if a.voucherCodes != nil { + a.voucherCodes.Start(time.Hour) + } + if a.voucherRecover != nil { + a.voucherRecover.Start(time.Minute) + } engine := a.router.Init() @@ -233,6 +259,18 @@ func (a *App) Shutdown() { if a.walletExpiry != nil { a.walletExpiry.Stop() } + if a.gameSessions != nil { + a.gameSessions.Stop() + } + if a.gameBudgets != nil { + a.gameBudgets.Stop() + } + if a.voucherCodes != nil { + a.voucherCodes.Stop() + } + if a.voucherRecover != nil { + a.voucherRecover.Stop() + } close(a.shutdown) } @@ -403,6 +441,11 @@ type processors struct { customerDeviceProcessor *processor.CustomerDeviceProcessor customerOutletProcessor *processor.CustomerOutletProcessor customerOrderProcessor *processor.CustomerOrderProcessor + enakGameAdminProcessor *processor.EnakGameAdminProcessor + gameBudgetProcessor *processor.GameBudgetProcessor + gameSessionProcessor *processor.GameSessionProcessor + voucherAdminProcessor *processor.VoucherAdminProcessor + voucherRedemptionProcessor *processor.VoucherRedemptionProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -418,6 +461,10 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo) loyaltySettingsProcessor := processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager) + auditLogger := processor.NewAuditLogger(repository.NewAuditLogRepository(a.db)) + enakGameRepo := repository.NewEnakGameRepository(a.db) + gameBudgetRepo := repository.NewGameBudgetRepository(a.db) + voucherRepo := repository.NewVoucherRepository(a.db) // Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3) earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) orderProcessor.SetLoyalty(earningProcessor) @@ -481,6 +528,17 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor customerOutletProcessor: processor.NewCustomerOutletProcessor(repository.NewCustomerOutletRepository(a.db), loyaltySettingsProcessor), customerOrderProcessor: processor.NewCustomerOrderProcessor(repository.NewCustomerOrderRepository(a.db), earningProcessor), walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), loyaltySettingsProcessor, repos.txManager), + // EnakGame (docs/rfc-enakgame.md) + enakGameAdminProcessor: processor.NewEnakGameAdminProcessor(enakGameRepo, auditLogger, repos.txManager), + gameBudgetProcessor: processor.NewGameBudgetProcessor(gameBudgetRepo, auditLogger, repos.txManager), + gameSessionProcessor: processor.NewGameSessionProcessor(repository.NewWalletMoveRepository(a.db), enakGameRepo, + repository.NewGameSessionRepository(a.db), gameBudgetRepo, repository.NewGameEventRepository(a.db), repository.NewGameRewardCounterRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), auditLogger, repos.txManager), + voucherAdminProcessor: processor.NewVoucherAdminProcessor(voucherRepo, auditLogger, repos.txManager), + voucherRedemptionProcessor: processor.NewVoucherRedemptionProcessor(repository.NewWalletMoveRepository(a.db), voucherRepo, + repository.NewVoucherRedemptionRepository(a.db), customerPinProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), + // No provider has an adapter yet (EG-801): EXTERNAL vouchers stay out of the + // catalog and cannot be redeemed until one is added here. + processor.VoucherProviders{}, repos.txManager), } } @@ -530,6 +588,8 @@ type services struct { customerDeviceService *service.CustomerDeviceServiceImpl customerOutletService *service.CustomerOutletServiceImpl customerOrderService *service.CustomerOrderServiceImpl + enakGameAdminService *service.EnakGameAdminServiceImpl + enakGameCustomerService *service.EnakGameCustomerServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -618,6 +678,12 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor), customerOutletService: service.NewCustomerOutletService(processors.customerOutletProcessor), customerOrderService: service.NewCustomerOrderService(processors.customerOrderProcessor), + enakGameAdminService: service.NewEnakGameAdminService(processors.enakGameAdminProcessor, processors.gameBudgetProcessor, + processor.NewGameBudgetMetricsProcessor(repository.NewGameBudgetRepository(a.db), repository.NewGameBudgetMetricsRepository(a.db)), + processor.NewGameEventProcessor(repository.NewGameEventRepository(a.db), repository.NewEnakGameRepository(a.db), repository.NewGameBudgetRepository(a.db), + processor.NewAuditLogger(repository.NewAuditLogRepository(a.db)), repos.txManager), + processors.voucherAdminProcessor), + enakGameCustomerService: service.NewEnakGameCustomerService(processors.gameSessionProcessor, processors.voucherRedemptionProcessor), } } diff --git a/internal/constants/enakgame.go b/internal/constants/enakgame.go new file mode 100644 index 0000000..9cee3b6 --- /dev/null +++ b/internal/constants/enakgame.go @@ -0,0 +1,146 @@ +package constants + +// EnakGame values (docs/rfc-enakgame.md §5). Each set matches a CHECK in the +// migrations. + +// games.status. +const ( + GameStatusDraft = "DRAFT" + GameStatusActive = "ACTIVE" + GameStatusInactive = "INACTIVE" + // The old games, and games taken out of EnakGame for good. Never changed again. + GameStatusArchived = "ARCHIVED" +) + +// game_reward_configs.reward_type (§8). +const ( + GameRewardTypeFixed = "FIXED" + GameRewardTypeScoreBased = "SCORE_BASED" + GameRewardTypeOutcomeBased = "OUTCOME_BASED" + GameRewardTypeProbability = "PROBABILITY" +) + +// game_reward_configs.status. Only status ever changes on a config (D7). +const ( + GameRewardConfigStatusDraft = "DRAFT" + GameRewardConfigStatusActive = "ACTIVE" + GameRewardConfigStatusRetired = "RETIRED" +) + +// game_sessions.status: STARTED → COMPLETED | REFUNDED | EXPIRED (D4). +const ( + GameSessionStatusStarted = "STARTED" + GameSessionStatusCompleted = "COMPLETED" + GameSessionStatusRefunded = "REFUNDED" + GameSessionStatusExpired = "EXPIRED" +) + +// game_sessions.refund_reason (§7.3). +const ( + GameSessionRefundSystemError = "SYSTEM_ERROR" + GameSessionRefundGameDeactivated = "GAME_DEACTIVATED" +) + +// game_budgets.scope (§5.6). +const ( + GameBudgetScopeGlobal = "GLOBAL" + GameBudgetScopeEvent = "EVENT" +) + +// audit_logs.actor_type (§5.9). +const ( + AuditActorUser = "USER" + AuditActorSystem = "SYSTEM" +) + +// audit_logs.source: where a change came from. +const ( + AuditSourceAdminAPI = "admin_api" + AuditSourceBudgetController = "budget_controller" + AuditSourceSessionJob = "session_job" + AuditSourceBudgetPeriodJob = "budget_period_job" + AuditSourceCustomerAPI = "customer_api" +) + +// audit_logs.entity_type: what an audit row is about (§13). +const ( + AuditEntityGame = "GAME" + AuditEntityGameRewardConfig = "GAME_REWARD_CONFIG" + AuditEntityGameBudget = "GAME_BUDGET" + AuditEntityGameEvent = "GAME_EVENT" + AuditEntityGameSession = "GAME_SESSION" + AuditEntityVoucher = "VOUCHER" +) + +// game_reward_counters.scope_type: what an Economy Guard counter counts for (§5.8). +const ( + GameRewardScopeUser = "USER" + GameRewardScopeGame = "GAME" + GameRewardScopeEvent = "EVENT" + GameRewardScopeGlobal = "GLOBAL" +) + +// vouchers.voucher_type (§5.7). +const ( + VoucherTypeFixedValue = "FIXED_VALUE" + VoucherTypePercentage = "PERCENTAGE" + VoucherTypeFreeItem = "FREE_ITEM" + VoucherTypeMerchantBenefit = "MERCHANT_BENEFIT" +) + +// vouchers.stock_mode: where a redemption's voucher comes from. +const ( + // A counted stock with nothing to hand out but the redemption itself. + VoucherStockStatic = "STATIC" + // One imported code per redemption. + VoucherStockCodePool = "CODE_POOL" + // Issued by a provider (§7.5), not before EG-801. + VoucherStockExternal = "EXTERNAL" +) + +// vouchers.status. +const ( + VoucherStatusDraft = "DRAFT" + VoucherStatusActive = "ACTIVE" + VoucherStatusInactive = "INACTIVE" + VoucherStatusArchived = "ARCHIVED" +) + +// voucher_codes.status. +const ( + VoucherCodeAvailable = "AVAILABLE" + VoucherCodeReserved = "RESERVED" + VoucherCodeRedeemed = "REDEEMED" + VoucherCodeExpired = "EXPIRED" + VoucherCodeCancelled = "CANCELLED" +) + +// voucher_redemptions.status. +const ( + VoucherRedemptionPending = "PENDING" + VoucherRedemptionCompleted = "COMPLETED" + VoucherRedemptionFailed = "FAILED" +) + +// Budget statuses (PRD §32), from its utilization and forecast against its thresholds. +const ( + GameBudgetHealthy = "HEALTHY" + GameBudgetWarning = "WARNING" + GameBudgetCritical = "CRITICAL" + GameBudgetExhausted = "EXHAUSTED" +) + +// Thresholds of a budget that sets none, in percent (PRD §8). Pending RFC §19.2 #4. +const ( + GameBudgetWarningDefault = int64(70) + GameBudgetCriticalDefault = int64(90) +) + +// game_events.status. An ACTIVE event changes rewards only between its start_at and +// end_at. +const ( + GameEventStatusDraft = "DRAFT" + GameEventStatusActive = "ACTIVE" + GameEventStatusEnded = "ENDED" + GameEventStatusCancelled = "CANCELLED" +) diff --git a/internal/constants/error.go b/internal/constants/error.go index e0ed8a6..c953e30 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -69,6 +69,7 @@ const ( ExpenseServiceEntity = "expense_service" CashAdvanceServiceEntity = "cash_advance_service" WalletServiceEntity = "wallet_service" + EnakGameServiceEntity = "enakgame_service" LoyaltySettingsServiceEntity = "loyalty_settings_service" CustomerPinServiceEntity = "customer_pin_service" ) diff --git a/internal/constants/loyalty.go b/internal/constants/loyalty.go index e4d06ca..e5c0976 100644 --- a/internal/constants/loyalty.go +++ b/internal/constants/loyalty.go @@ -47,6 +47,13 @@ const ( LoyaltyExpiryGraceMonthsSuffix = "expiry_grace_months" ) +// Per organization: EnakGame reward limits (docs/rfc-enakgame.md §5.10). 0 means no +// limit. A reward over a limit is cut to what is left of it (§9). +const ( + EnakGameLimitUserDailyKey = "enakgame.limit.user_daily" + EnakGameLimitGlobalDailyKey = "enakgame.limit.global_daily" +) + // Modes of loyalty.{point,coin}.earn_mode. const ( // earn_value for every earn_per_amount rupiah of the basis. diff --git a/internal/constants/wallet.go b/internal/constants/wallet.go index 7a0e84f..dc2e5ec 100644 --- a/internal/constants/wallet.go +++ b/internal/constants/wallet.go @@ -25,6 +25,11 @@ const ( WalletTxTypeAdjustment = "ADJUSTMENT" WalletTxTypeMigration = "MIGRATION" WalletTxTypeRewardRedeem = "REWARD_REDEEM" + + // EnakGame (docs/rfc-enakgame.md §6.1). + WalletTxTypeGameSpendRefund = "GAME_SPEND_REFUND" + WalletTxTypeGameReward = "GAME_REWARD" + WalletTxTypeRewardRedeemRefund = "REWARD_REDEEM_REFUND" ) // What a ledger row's reference_id points at: where the value came from for a @@ -38,4 +43,5 @@ const ( WalletRefTypeLegacyPoints = "LEGACY_POINTS" WalletRefTypeLegacyTokens = "LEGACY_TOKENS" WalletRefTypeRewardRedemption = "REWARD_REDEMPTION" + WalletRefTypeGameSession = "GAME_SESSION" ) diff --git a/internal/contract/enakgame_contract.go b/internal/contract/enakgame_contract.go new file mode 100644 index 0000000..fd55fc7 --- /dev/null +++ b/internal/contract/enakgame_contract.go @@ -0,0 +1,15 @@ +package contract + +import "github.com/google/uuid" + +// StartGameSessionRequest is POST /customer/enakgame/sessions (docs/rfc-enakgame.md +// §7.1). The Idempotency-Key header is required. +type StartGameSessionRequest struct { + GameID uuid.UUID `json:"game_id" binding:"required"` +} + +// RedeemVoucherRequest is POST /customer/enakgame/vouchers/:id/redeem (§7.4). The +// Idempotency-Key header is required. +type RedeemVoucherRequest struct { + Pin string `json:"pin" binding:"required"` +} diff --git a/internal/entities/audit_log.go b/internal/entities/audit_log.go new file mode 100644 index 0000000..03821d2 --- /dev/null +++ b/internal/entities/audit_log.go @@ -0,0 +1,30 @@ +package entities + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// AuditLog is one change to EnakGame configuration or state: who made it, to what, +// from what, to what (docs/rfc-enakgame.md §5.9, §13). Append-only. +type AuditLog struct { + ID uuid.UUID + OrganizationID uuid.UUID + // USER or SYSTEM. + ActorType string + // The admin for USER; nil for SYSTEM. + ActorID *uuid.UUID + EntityType string + EntityID uuid.UUID + Action string + // JSON snapshots of the entity; nil when there was nothing before (a create) or + // after (a delete). + Before json.RawMessage + After json.RawMessage + Reason *string + // admin_api, budget_controller, session_job, ... + Source string + CreatedAt time.Time +} diff --git a/internal/entities/enakgame.go b/internal/entities/enakgame.go new file mode 100644 index 0000000..797595b --- /dev/null +++ b/internal/entities/enakgame.go @@ -0,0 +1,197 @@ +package entities + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" +) + +// JSONDocument is a JSONB column kept as raw JSON, for documents whose shape depends +// on another column or is only read back as a whole. Empty is SQL NULL. +type JSONDocument json.RawMessage + +func (d JSONDocument) Value() (driver.Value, error) { + if len(d) == 0 { + return nil, nil + } + return string(d), nil +} + +func (d *JSONDocument) Scan(value interface{}) error { + switch v := value.(type) { + case nil: + *d = nil + case []byte: + *d = append(JSONDocument(nil), v...) + case string: + *d = JSONDocument(v) + default: + return errors.New("JSONDocument: unsupported type") + } + return nil +} + +func (d JSONDocument) MarshalJSON() ([]byte, error) { + if len(d) == 0 { + return []byte("null"), nil + } + return d, nil +} + +func (d *JSONDocument) UnmarshalJSON(data []byte) error { + *d = append(JSONDocument(nil), data...) + return nil +} + +// GameResultRules are a game's limits on a result (docs/rfc-enakgame.md §5.1, §7.2) +// and its daily reward limit (§5.10, §9). A limit left out does not apply. +type GameResultRules struct { + MaxScore *int64 `json:"max_score,omitempty"` + MinDurationSeconds *int64 `json:"min_duration_seconds,omitempty"` + MaxScorePerSecond *float64 `json:"max_score_per_second,omitempty"` + // The outcomes a result may report. Empty accepts any. + Outcomes []string `json:"outcomes,omitempty"` + // EnakCoin the game may give out per day, in Asia/Jakarta. + DailyRewardLimit *int64 `json:"daily_reward_limit,omitempty"` +} + +func (r GameResultRules) Value() (driver.Value, error) { + b, err := json.Marshal(r) + if err != nil { + return nil, err + } + return string(b), nil +} + +func (r *GameResultRules) Scan(value interface{}) error { + *r = GameResultRules{} + switch v := value.(type) { + case nil: + return nil + case []byte: + return json.Unmarshal(v, r) + case string: + return json.Unmarshal([]byte(v), r) + default: + return errors.New("GameResultRules: unsupported type") + } +} + +// GameRewardConfig is one version of how a game computes its base reward (§5.2, §8). +// Never updated apart from Status (D7): a change is a new version. +type GameRewardConfig struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` + GameID uuid.UUID `gorm:"type:uuid;not null" json:"game_id"` + Version int `gorm:"not null" json:"version"` + // FIXED, SCORE_BASED, OUTCOME_BASED or PROBABILITY. + RewardType string `gorm:"size:30;not null" json:"reward_type"` + // Shape per RewardType in §8. + Rules JSONDocument `gorm:"type:jsonb;not null" json:"rules"` + MaxReward int64 `gorm:"not null" json:"max_reward"` + // DRAFT, ACTIVE or RETIRED. + Status string `gorm:"size:20;not null" json:"status"` + EffectiveAt *time.Time `json:"effective_at"` + CreatedBy uuid.UUID `gorm:"type:uuid;not null" json:"created_by"` + Reason *string `gorm:"size:255" json:"reason"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (GameRewardConfig) TableName() string { return "game_reward_configs" } + +// GameSession is one play of a game by a customer (§5.3): STARTED, then COMPLETED, +// REFUNDED or EXPIRED, each move a conditional update (D4). +type GameSession struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` + CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"` + GameID uuid.UUID `gorm:"type:uuid;not null" json:"game_id"` + // Snapshots taken at start (D7, P3). + RewardConfigID uuid.UUID `gorm:"type:uuid;not null" json:"reward_config_id"` + EntryCost int64 `gorm:"not null" json:"entry_cost"` + + Status string `gorm:"size:20;not null" json:"status"` + StartedAt time.Time `gorm:"not null" json:"started_at"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at"` + EndedAt *time.Time `json:"ended_at"` + + // What the client sent: data only, never a reward amount (P1). + Result JSONDocument `gorm:"type:jsonb" json:"result"` + RewardBreakdown JSONDocument `gorm:"type:jsonb" json:"reward_breakdown"` + RewardTotal int64 `gorm:"not null;default:0" json:"reward_total"` + Flagged bool `gorm:"not null;default:false" json:"flagged"` + + SpendTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"spend_transaction_id"` + RefundTransactionID *uuid.UUID `gorm:"type:uuid" json:"refund_transaction_id"` + // SYSTEM_ERROR or GAME_DEACTIVATED. + RefundReason *string `gorm:"size:30" json:"refund_reason"` + CompletionFailedAt *time.Time `json:"completion_failed_at"` + + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (GameSession) TableName() string { return "game_sessions" } + +// GameSessionReward is the part of a session's reward one budget paid for, with its +// own ledger row (D6). +type GameSessionReward struct { + SessionID uuid.UUID `gorm:"type:uuid;primary_key" json:"session_id"` + BudgetID uuid.UUID `gorm:"type:uuid;primary_key" json:"budget_id"` + Amount int64 `gorm:"not null" json:"amount"` + WalletTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"wallet_transaction_id"` +} + +func (GameSessionReward) TableName() string { return "game_session_rewards" } + +// GameBudget is what an organization may spend on EnakGame rewards over a period +// (§5.6): GLOBAL for base rewards, EVENT for what an event adds. +type GameBudget struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` + // GLOBAL or EVENT. + Scope string `gorm:"size:20;not null" json:"scope"` + Name string `gorm:"size:255;not null" json:"name"` + // Dates, inclusive at both ends. + PeriodStart time.Time `gorm:"type:date;not null" json:"period_start"` + PeriodEnd time.Time `gorm:"type:date;not null" json:"period_end"` + // Rupiah. + Amount int64 `gorm:"not null" json:"amount"` + // {"warning": 70, "critical": 90}. + Thresholds JSONDocument `gorm:"type:jsonb;not null;default:'{}'" json:"thresholds"` + ExhaustionPolicy *string `gorm:"size:30" json:"exhaustion_policy"` + CreatedBy uuid.UUID `gorm:"type:uuid;not null" json:"created_by"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (GameBudget) TableName() string { return "game_budgets" } + +// GameEvent is a period in which some games pay more, the extra paid by the event's +// own budget (docs/rfc-enakgame.md §5.5, D6). Event and campaign are the same thing. +type GameEvent struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` + Name string `gorm:"size:255;not null" json:"name"` + Slug string `gorm:"size:100;not null" json:"slug"` + Description *string `gorm:"type:text" json:"description"` + BannerURL *string `gorm:"column:banner_url;size:500" json:"banner_url"` + StartAt time.Time `gorm:"not null" json:"start_at"` + EndAt time.Time `gorm:"not null" json:"end_at"` + Timezone string `gorm:"size:50;not null" json:"timezone"` + // DRAFT, ACTIVE, ENDED or CANCELLED. + Status string `gorm:"size:20;not null" json:"status"` + Priority int `gorm:"not null" json:"priority"` + // NUMERIC(5,2): at most two decimals. + Multiplier *float64 `gorm:"type:numeric(5,2)" json:"multiplier"` + Bonus *int64 `json:"bonus"` + BudgetID uuid.UUID `gorm:"type:uuid;not null" json:"budget_id"` + RewardLimit *int64 `json:"reward_limit"` + UserDailyLimit *int64 `json:"user_daily_limit"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (GameEvent) TableName() string { return "game_events" } diff --git a/internal/entities/game.go b/internal/entities/game.go index a230f72..7effc74 100644 --- a/internal/entities/game.go +++ b/internal/entities/game.go @@ -16,11 +16,27 @@ const ( ) type Game struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"` - Type GameType `gorm:"type:varchar(50);not null" json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"` - IsActive bool `gorm:"default:true" json:"is_active"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"` + Type GameType `gorm:"type:varchar(50);not null" json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"` + IsActive bool `gorm:"default:true" json:"is_active"` + Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` + + // EnakGame (docs/rfc-enakgame.md §5.1). Only an ARCHIVED game, which every old game + // is, may lack an organization and a slug. + OrganizationID *uuid.UUID `gorm:"type:uuid" json:"organization_id"` + Slug *string `gorm:"size:100" json:"slug"` + Description *string `gorm:"type:text" json:"description"` + ThumbnailURL *string `gorm:"column:thumbnail_url;size:500" json:"thumbnail_url"` + GameURL *string `gorm:"column:game_url;size:500" json:"game_url"` + Version *string `gorm:"size:50" json:"version"` + // DRAFT, ACTIVE, INACTIVE or ARCHIVED. + Status string `gorm:"size:20;not null;default:ACTIVE" json:"status"` + // EnakCoin a session costs; at least 1. + EntryCost int64 `gorm:"not null" json:"entry_cost"` + SessionTTLSeconds int `gorm:"column:session_ttl_seconds;not null;default:600" json:"session_ttl_seconds"` + ResultRules GameResultRules `gorm:"type:jsonb;not null;default:'{}'" json:"result_rules"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` diff --git a/internal/entities/voucher.go b/internal/entities/voucher.go new file mode 100644 index 0000000..e23dbad --- /dev/null +++ b/internal/entities/voucher.go @@ -0,0 +1,88 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +// Voucher is what EnakPoint is redeemed for (docs/rfc-enakgame.md §5.7). +type Voucher struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` + Name string `gorm:"size:255;not null" json:"name"` + Description *string `gorm:"type:text" json:"description"` + ImageURL *string `gorm:"column:image_url;size:500" json:"image_url"` + VoucherType string `gorm:"size:30;not null" json:"voucher_type"` + // Rupiah: the basis of realized cost. + FaceValue int64 `gorm:"not null" json:"face_value"` + PointCost int64 `gorm:"not null" json:"point_cost"` + // Reporting only. + BusinessCost *int64 `json:"business_cost"` + StockMode string `gorm:"size:20;not null" json:"stock_mode"` + // STATIC only. + Stock *int64 `json:"stock"` + // EXTERNAL only. + Provider *string `gorm:"size:50" json:"provider"` + ProviderRef *string `gorm:"size:255" json:"provider_ref"` + MaxPerCustomer *int `json:"max_per_customer"` + ValidFrom *time.Time `json:"valid_from"` + ValidUntil *time.Time `json:"valid_until"` + Terms JSONDocument `gorm:"type:jsonb;not null;default:'{}'" json:"terms"` + Status string `gorm:"size:20;not null" json:"status"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (Voucher) TableName() string { return "vouchers" } + +// VoucherCode is one code of a CODE_POOL voucher. +type VoucherCode struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + VoucherID uuid.UUID `gorm:"type:uuid;not null" json:"voucher_id"` + Code string `gorm:"size:255;not null" json:"code"` + Status string `gorm:"size:20;not null" json:"status"` + RedemptionID *uuid.UUID `gorm:"type:uuid" json:"redemption_id"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (VoucherCode) TableName() string { return "voucher_codes" } + +// VoucherRedemption is one redemption, with the voucher's numbers frozen (P3). +type VoucherRedemption struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` + CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"` + VoucherID uuid.UUID `gorm:"type:uuid;not null" json:"voucher_id"` + IdempotencyKey string `gorm:"size:100;not null" json:"idempotency_key"` + Status string `gorm:"size:20;not null" json:"status"` + FaceValue int64 `gorm:"not null" json:"face_value"` + PointCost int64 `gorm:"not null" json:"point_cost"` + VoucherCodeID *uuid.UUID `gorm:"type:uuid" json:"voucher_code_id"` + ExternalCode *string `gorm:"size:255" json:"external_code"` + ExternalRef *string `gorm:"size:255" json:"external_ref"` + DebitTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"debit_transaction_id"` + RefundTransactionID *uuid.UUID `gorm:"type:uuid" json:"refund_transaction_id"` + FailureReason *string `gorm:"size:255" json:"failure_reason"` + Attempts int `gorm:"not null;default:0" json:"attempts"` + CompletedAt *time.Time `json:"completed_at"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (VoucherRedemption) TableName() string { return "voucher_redemptions" } + +// VoucherRedemptionCost is the part of a redemption's face value one source of its +// EnakPoint accounts for (D5). BudgetID is nil for EnakPoint not from EnakGame. +type VoucherRedemptionCost struct { + RedemptionID uuid.UUID `gorm:"type:uuid;not null" json:"redemption_id"` + BudgetID *uuid.UUID `gorm:"type:uuid" json:"budget_id"` + SourceType string `gorm:"size:30;not null" json:"source_type"` + Points int64 `gorm:"not null" json:"points"` + Cost int64 `gorm:"not null" json:"cost"` + RecognizedAt time.Time `gorm:"not null" json:"recognized_at"` +} + +func (VoucherRedemptionCost) TableName() string { return "voucher_redemption_costs" } diff --git a/internal/handler/customer_wallet_db_test.go b/internal/handler/customer_wallet_db_test.go index 2b36685..7a0a6fc 100644 --- a/internal/handler/customer_wallet_db_test.go +++ b/internal/handler/customer_wallet_db_test.go @@ -181,7 +181,7 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) { assert.Len(t, first["lots"], 2) today := time.Now().In(time.FixedZone("WIB", 7*3600)).Format("2006-01-02") - status, body = get("/a/wallet/transactions?from=" + today + "&to=" + today + "&type=PAYMENT,MIGRATION") + status, body = get("/a/wallet/transactions?from=" + today + "&to=" + today + "&type=REWARD_REDEEM,MIGRATION") require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 2, body["data"].(map[string]any)["pagination"].(map[string]any)["total_count"]) diff --git a/internal/handler/enakgame_admin_handler.go b/internal/handler/enakgame_admin_handler.go new file mode 100644 index 0000000..c91869c --- /dev/null +++ b/internal/handler/enakgame_admin_handler.go @@ -0,0 +1,410 @@ +package handler + +import ( + "io" + "mime/multipart" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" +) + +// EnakGameAdminHandler serves /marketing/enakgame (docs/rfc-enakgame.md §11). Every +// request works on the admin's own organization. +type EnakGameAdminHandler struct { + service service.EnakGameAdminService +} + +func NewEnakGameAdminHandler(s service.EnakGameAdminService) *EnakGameAdminHandler { + return &EnakGameAdminHandler{service: s} +} + +// voucherCodeUploadLimit caps a CSV of codes: 50 000 lines of up to 255 characters. +const voucherCodeUploadLimit = 16 << 20 + +// pathID reads a UUID path parameter, answering 400 when it is not one. +func pathID(c *gin.Context, name, method string) (uuid.UUID, bool) { + id, err := uuid.Parse(c.Param(name)) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "invalid "+name), + }), method) + return uuid.Nil, false + } + return id, true +} + +// rawBody reads the request body, answering 400 when it cannot. +func rawBody(c *gin.Context, method string) ([]byte, bool) { + body, err := c.GetRawData() + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "cannot read the request body"), + }), method) + return nil, false + } + return body, true +} + +func bindQuery(c *gin.Context, q any, method string) bool { + if err := c.ShouldBindQuery(q); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()), + }), method) + return false + } + return true +} + +// CreateGame is POST /marketing/enakgame/games. +func (h *EnakGameAdminHandler) CreateGame(c *gin.Context) { + const method = "EnakGameAdminHandler::CreateGame" + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.CreateGame(ctx, appcontext.FromGinContext(ctx), body), method) +} + +// ListGames is GET /marketing/enakgame/games?status=&search=&page=&limit=. +func (h *EnakGameAdminHandler) ListGames(c *gin.Context) { + const method = "EnakGameAdminHandler::ListGames" + var q models.EnakGameListQuery + if !bindQuery(c, &q, method) { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.ListGames(ctx, appcontext.FromGinContext(ctx), q), method) +} + +// GetGame is GET /marketing/enakgame/games/:id. +func (h *EnakGameAdminHandler) GetGame(c *gin.Context) { + const method = "EnakGameAdminHandler::GetGame" + id, ok := pathID(c, "id", method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.GetGame(ctx, appcontext.FromGinContext(ctx), id), method) +} + +// UpdateGame is PUT /marketing/enakgame/games/:id. +func (h *EnakGameAdminHandler) UpdateGame(c *gin.Context) { + const method = "EnakGameAdminHandler::UpdateGame" + id, ok := pathID(c, "id", method) + if !ok { + return + } + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.UpdateGame(ctx, appcontext.FromGinContext(ctx), id, body), method) +} + +// SetGameStatus is PUT /marketing/enakgame/games/:id/status. +func (h *EnakGameAdminHandler) SetGameStatus(c *gin.Context) { + const method = "EnakGameAdminHandler::SetGameStatus" + id, ok := pathID(c, "id", method) + if !ok { + return + } + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.SetGameStatus(ctx, appcontext.FromGinContext(ctx), id, body), method) +} + +// CreateRewardConfig is POST /marketing/enakgame/games/:id/reward-configs. +func (h *EnakGameAdminHandler) CreateRewardConfig(c *gin.Context) { + const method = "EnakGameAdminHandler::CreateRewardConfig" + id, ok := pathID(c, "id", method) + if !ok { + return + } + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.CreateRewardConfig(ctx, appcontext.FromGinContext(ctx), id, body), method) +} + +// ListRewardConfigs is GET /marketing/enakgame/games/:id/reward-configs. +func (h *EnakGameAdminHandler) ListRewardConfigs(c *gin.Context) { + const method = "EnakGameAdminHandler::ListRewardConfigs" + id, ok := pathID(c, "id", method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.ListRewardConfigs(ctx, appcontext.FromGinContext(ctx), id), method) +} + +// ActivateRewardConfig is POST /marketing/enakgame/reward-configs/:id/activate. +func (h *EnakGameAdminHandler) ActivateRewardConfig(c *gin.Context) { + const method = "EnakGameAdminHandler::ActivateRewardConfig" + id, ok := pathID(c, "id", method) + if !ok { + return + } + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.ActivateRewardConfig(ctx, appcontext.FromGinContext(ctx), id, body), method) +} + +// CreateBudget is POST /marketing/enakgame/budgets. +func (h *EnakGameAdminHandler) CreateBudget(c *gin.Context) { + const method = "EnakGameAdminHandler::CreateBudget" + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.CreateBudget(ctx, appcontext.FromGinContext(ctx), body), method) +} + +// ListBudgets is GET /marketing/enakgame/budgets?scope=&page=&limit=. +func (h *EnakGameAdminHandler) ListBudgets(c *gin.Context) { + const method = "EnakGameAdminHandler::ListBudgets" + var q models.GameBudgetListQuery + if !bindQuery(c, &q, method) { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.ListBudgets(ctx, appcontext.FromGinContext(ctx), q), method) +} + +// GetBudget is GET /marketing/enakgame/budgets/:id. +func (h *EnakGameAdminHandler) GetBudget(c *gin.Context) { + const method = "EnakGameAdminHandler::GetBudget" + id, ok := pathID(c, "id", method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.GetBudget(ctx, appcontext.FromGinContext(ctx), id), method) +} + +// UpdateBudget is PUT /marketing/enakgame/budgets/:id. +func (h *EnakGameAdminHandler) UpdateBudget(c *gin.Context) { + const method = "EnakGameAdminHandler::UpdateBudget" + id, ok := pathID(c, "id", method) + if !ok { + return + } + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.UpdateBudget(ctx, appcontext.FromGinContext(ctx), id, body), method) +} + +// DeleteBudget is DELETE /marketing/enakgame/budgets/:id. +func (h *EnakGameAdminHandler) DeleteBudget(c *gin.Context) { + const method = "EnakGameAdminHandler::DeleteBudget" + id, ok := pathID(c, "id", method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.DeleteBudget(ctx, appcontext.FromGinContext(ctx), id), method) +} + +// BudgetMetrics is GET /marketing/enakgame/budgets/:id/metrics. +func (h *EnakGameAdminHandler) BudgetMetrics(c *gin.Context) { + const method = "EnakGameAdminHandler::BudgetMetrics" + id, ok := pathID(c, "id", method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.BudgetMetrics(ctx, appcontext.FromGinContext(ctx), id), method) +} + +// CreateVoucher is POST /marketing/enakgame/vouchers. +func (h *EnakGameAdminHandler) CreateVoucher(c *gin.Context) { + const method = "EnakGameAdminHandler::CreateVoucher" + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.CreateVoucher(ctx, appcontext.FromGinContext(ctx), body), method) +} + +// ListVouchers is GET /marketing/enakgame/vouchers?status=&search=&page=&limit=. +func (h *EnakGameAdminHandler) ListVouchers(c *gin.Context) { + const method = "EnakGameAdminHandler::ListVouchers" + var q models.VoucherListQuery + if !bindQuery(c, &q, method) { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.ListVouchers(ctx, appcontext.FromGinContext(ctx), q), method) +} + +// GetVoucher is GET /marketing/enakgame/vouchers/:id. +func (h *EnakGameAdminHandler) GetVoucher(c *gin.Context) { + const method = "EnakGameAdminHandler::GetVoucher" + id, ok := pathID(c, "id", method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.GetVoucher(ctx, appcontext.FromGinContext(ctx), id), method) +} + +// UpdateVoucher is PUT /marketing/enakgame/vouchers/:id. +func (h *EnakGameAdminHandler) UpdateVoucher(c *gin.Context) { + const method = "EnakGameAdminHandler::UpdateVoucher" + id, ok := pathID(c, "id", method) + if !ok { + return + } + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.UpdateVoucher(ctx, appcontext.FromGinContext(ctx), id, body), method) +} + +// SetVoucherStatus is PUT /marketing/enakgame/vouchers/:id/status. +func (h *EnakGameAdminHandler) SetVoucherStatus(c *gin.Context) { + const method = "EnakGameAdminHandler::SetVoucherStatus" + id, ok := pathID(c, "id", method) + if !ok { + return + } + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.SetVoucherStatus(ctx, appcontext.FromGinContext(ctx), id, body), method) +} + +// ImportVoucherCodes is POST /marketing/enakgame/vouchers/:id/codes, with the CSV as +// a multipart file named "file" or as the request body. +func (h *EnakGameAdminHandler) ImportVoucherCodes(c *gin.Context) { + const method = "EnakGameAdminHandler::ImportVoucherCodes" + id, ok := pathID(c, "id", method) + if !ok { + return + } + var data []byte + if strings.HasPrefix(c.ContentType(), "multipart/") { + file, err := c.FormFile("file") + if err == nil { + var f multipart.File + if f, err = file.Open(); err == nil { + data, err = io.ReadAll(io.LimitReader(f, voucherCodeUploadLimit)) + f.Close() + } + } + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "send the CSV as a file named \"file\""), + }), method) + return + } + } else if data, ok = rawBody(c, method); !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.ImportVoucherCodes(ctx, appcontext.FromGinContext(ctx), id, data), method) +} + +// ListVoucherCodes is GET /marketing/enakgame/vouchers/:id/codes?status=&page=&limit=. +func (h *EnakGameAdminHandler) ListVoucherCodes(c *gin.Context) { + const method = "EnakGameAdminHandler::ListVoucherCodes" + id, ok := pathID(c, "id", method) + if !ok { + return + } + var q models.VoucherCodeListQuery + if !bindQuery(c, &q, method) { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.ListVoucherCodes(ctx, appcontext.FromGinContext(ctx), id, q), method) +} + +// CreateEvent is POST /marketing/enakgame/events. +func (h *EnakGameAdminHandler) CreateEvent(c *gin.Context) { + const method = "EnakGameAdminHandler::CreateEvent" + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.CreateEvent(ctx, appcontext.FromGinContext(ctx), body), method) +} + +// ListEvents is GET /marketing/enakgame/events?status=&page=&limit=. +func (h *EnakGameAdminHandler) ListEvents(c *gin.Context) { + const method = "EnakGameAdminHandler::ListEvents" + var q models.GameEventListQuery + if !bindQuery(c, &q, method) { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.ListEvents(ctx, appcontext.FromGinContext(ctx), q), method) +} + +// GetEvent is GET /marketing/enakgame/events/:id. +func (h *EnakGameAdminHandler) GetEvent(c *gin.Context) { + const method = "EnakGameAdminHandler::GetEvent" + id, ok := pathID(c, "id", method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.GetEvent(ctx, appcontext.FromGinContext(ctx), id), method) +} + +// UpdateEvent is PUT /marketing/enakgame/events/:id. +func (h *EnakGameAdminHandler) UpdateEvent(c *gin.Context) { + const method = "EnakGameAdminHandler::UpdateEvent" + id, ok := pathID(c, "id", method) + if !ok { + return + } + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.UpdateEvent(ctx, appcontext.FromGinContext(ctx), id, body), method) +} + +// SetEventStatus is PUT /marketing/enakgame/events/:id/status. +func (h *EnakGameAdminHandler) SetEventStatus(c *gin.Context) { + const method = "EnakGameAdminHandler::SetEventStatus" + id, ok := pathID(c, "id", method) + if !ok { + return + } + body, ok := rawBody(c, method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.SetEventStatus(ctx, appcontext.FromGinContext(ctx), id, body), method) +} diff --git a/internal/handler/enakgame_customer_handler.go b/internal/handler/enakgame_customer_handler.go new file mode 100644 index 0000000..6890d07 --- /dev/null +++ b/internal/handler/enakgame_customer_handler.go @@ -0,0 +1,148 @@ +package handler + +import ( + "bytes" + "encoding/json" + "strconv" + + "github.com/gin-gonic/gin" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" +) + +// EnakGameCustomerHandler serves /customer/enakgame (docs/rfc-enakgame.md §11). +type EnakGameCustomerHandler struct { + service service.EnakGameCustomerService +} + +func NewEnakGameCustomerHandler(s service.EnakGameCustomerService) *EnakGameCustomerHandler { + return &EnakGameCustomerHandler{service: s} +} + +// ListGames is GET /customer/enakgame/games. +func (h *EnakGameCustomerHandler) ListGames(c *gin.Context) { + const method = "EnakGameCustomerHandler::ListGames" + customerID, ok := customerIDFromGin(c, method) + if !ok { + return + } + util.HandleResponse(c.Writer, c.Request, h.service.ListGames(c.Request.Context(), customerID), method) +} + +// StartSession is POST /customer/enakgame/sessions. It requires Idempotency-Key. +func (h *EnakGameCustomerHandler) StartSession(c *gin.Context) { + const method = "EnakGameCustomerHandler::StartSession" + customerID, ok := customerIDFromGin(c, method) + if !ok { + return + } + var req contract.StartGameSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "game_id is required"), + }), method) + return + } + util.HandleResponse(c.Writer, c.Request, h.service.StartSession(c.Request.Context(), customerID, &req, idempotencyKey(c)), method) +} + +// ListSessions is GET /customer/enakgame/sessions?page=&limit=. +func (h *EnakGameCustomerHandler) ListSessions(c *gin.Context) { + const method = "EnakGameCustomerHandler::ListSessions" + customerID, ok := customerIDFromGin(c, method) + if !ok { + return + } + page, _ := strconv.Atoi(c.Query("page")) + limit, _ := strconv.Atoi(c.Query("limit")) + util.HandleResponse(c.Writer, c.Request, h.service.ListSessions(c.Request.Context(), customerID, page, limit), method) +} + +// GetSession is GET /customer/enakgame/sessions/:id. +func (h *EnakGameCustomerHandler) GetSession(c *gin.Context) { + const method = "EnakGameCustomerHandler::GetSession" + customerID, ok := customerIDFromGin(c, method) + if !ok { + return + } + id, ok := pathID(c, "id", method) + if !ok { + return + } + util.HandleResponse(c.Writer, c.Request, h.service.GetSession(c.Request.Context(), customerID, id), method) +} + +// CompleteSession is POST /customer/enakgame/sessions/:id/complete. The body is read +// leniently on purpose: a reward amount, or anything else the backend does not take +// from the client, is ignored rather than refused (P1). An empty body is a result +// with nothing in it. +func (h *EnakGameCustomerHandler) CompleteSession(c *gin.Context) { + const method = "EnakGameCustomerHandler::CompleteSession" + customerID, ok := customerIDFromGin(c, method) + if !ok { + return + } + id, ok := pathID(c, "id", method) + if !ok { + return + } + body, ok := rawBody(c, method) + if !ok { + return + } + var in models.GameSessionCompleteInput + if len(bytes.TrimSpace(body)) > 0 { + if err := json.Unmarshal(body, &in); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "score must be a whole number and outcome a text"), + }), method) + return + } + } + util.HandleResponse(c.Writer, c.Request, h.service.CompleteSession(c.Request.Context(), customerID, id, in), method) +} + +// ListVouchers is GET /customer/enakgame/vouchers. +func (h *EnakGameCustomerHandler) ListVouchers(c *gin.Context) { + const method = "EnakGameCustomerHandler::ListVouchers" + customerID, ok := customerIDFromGin(c, method) + if !ok { + return + } + util.HandleResponse(c.Writer, c.Request, h.service.ListVouchers(c.Request.Context(), customerID), method) +} + +// RedeemVoucher is POST /customer/enakgame/vouchers/:id/redeem. It requires the PIN +// and Idempotency-Key; the body holds the PIN, so it is never logged. +func (h *EnakGameCustomerHandler) RedeemVoucher(c *gin.Context) { + const method = "EnakGameCustomerHandler::RedeemVoucher" + customerID, ok := customerIDFromGin(c, method) + if !ok { + return + } + id, ok := pathID(c, "id", method) + if !ok { + return + } + var req contract.RedeemVoucherRequest + if !bindPinRequest(c, &req, method) { + return + } + util.HandleResponse(c.Writer, c.Request, h.service.RedeemVoucher(c.Request.Context(), customerID, id, &req, idempotencyKey(c), pinRequestInfo(c)), method) +} + +// ListRedemptions is GET /customer/enakgame/redemptions?page=&limit=. +func (h *EnakGameCustomerHandler) ListRedemptions(c *gin.Context) { + const method = "EnakGameCustomerHandler::ListRedemptions" + customerID, ok := customerIDFromGin(c, method) + if !ok { + return + } + page, _ := strconv.Atoi(c.Query("page")) + limit, _ := strconv.Atoi(c.Query("limit")) + util.HandleResponse(c.Writer, c.Request, h.service.ListRedemptions(c.Request.Context(), customerID, page, limit), method) +} diff --git a/internal/handler/enakgame_db_test.go b/internal/handler/enakgame_db_test.go new file mode 100644 index 0000000..5377ac0 --- /dev/null +++ b/internal/handler/enakgame_db_test.go @@ -0,0 +1,476 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "apskel-pos-be/internal/appcontext" + applogger "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/middleware" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" + "apskel-pos-be/internal/service" +) + +// enakGameHandlers builds both EnakGame handlers on db, the way app.go does. +func enakGameHandlers(db *gorm.DB) (*EnakGameAdminHandler, *EnakGameCustomerHandler) { + txm := repository.NewTxManager(db) + audit := processor.NewAuditLogger(repository.NewAuditLogRepository(db)) + games := repository.NewEnakGameRepository(db) + budgets := repository.NewGameBudgetRepository(db) + vouchers := repository.NewVoucherRepository(db) + wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) + customers := repository.NewWalletMoveRepository(db) + spendable := repository.NewWalletQueryRepository(db) + admin := NewEnakGameAdminHandler(service.NewEnakGameAdminService( + processor.NewEnakGameAdminProcessor(games, audit, txm), processor.NewGameBudgetProcessor(budgets, audit, txm), + processor.NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(db)), + processor.NewGameEventProcessor(repository.NewGameEventRepository(db), games, budgets, audit, txm), + processor.NewVoucherAdminProcessor(vouchers, audit, txm))) + customer := NewEnakGameCustomerHandler(service.NewEnakGameCustomerService( + processor.NewGameSessionProcessor(customers, games, repository.NewGameSessionRepository(db), budgets, + repository.NewGameEventRepository(db), repository.NewGameRewardCounterRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm), + spendable, wallet, audit, txm), + processor.NewVoucherRedemptionProcessor(customers, vouchers, repository.NewVoucherRedemptionRepository(db), + processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), nil, nil), spendable, wallet, processor.VoucherProviders{}, txm))) + return admin, customer +} + +// Drives /marketing/enakgame and /customer/enakgame over HTTP down to Postgres +// (docs/rfc-enakgame.md §11). Needs TEST_DATABASE_URL; see +// internal/repository/wallet_repository_test.go. +func TestEnakGameEndpoints_AgainstPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + applogger.Setup("fatal", "json") + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + + org, admin, customer := uuid.New(), uuid.New(), uuid.New() + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame http', 'basic')`, org).Error) + require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org).Error) + t.Cleanup(func() { + for _, q := range []string{ + `DELETE FROM audit_logs WHERE organization_id = ?`, + `DELETE FROM game_reward_counters WHERE organization_id = ?`, + `DELETE FROM game_sessions WHERE organization_id = ?`, + `DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, + `DELETE FROM wallet_lots WHERE organization_id = ?`, + `DELETE FROM wallet_transactions WHERE organization_id = ?`, + `DELETE FROM customer_wallets WHERE organization_id = ?`, + `DELETE FROM game_event_games WHERE event_id IN (SELECT id FROM game_events WHERE organization_id = ?)`, + `DELETE FROM game_events WHERE organization_id = ?`, + `DELETE FROM game_reward_configs WHERE organization_id = ?`, + `DELETE FROM game_budgets WHERE organization_id = ?`, + `DELETE FROM games WHERE organization_id = ?`, + `DELETE FROM customers WHERE organization_id = ?`, + `DELETE FROM organizations WHERE id = ?`, + } { + db.Exec(q, org) + } + }) + + txm := repository.NewTxManager(db) + wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) + adminHandler, customerHandler := enakGameHandlers(db) + + auth := middleware.NewAuthMiddleware(nil) + gin.SetMode(gin.TestMode) + router := gin.New() + for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} { + role := role + g := router.Group(prefix+"/enakgame", func(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String()) + ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String()) + ctx = context.WithValue(ctx, appcontext.UserRoleKey, role) + c.Request = c.Request.WithContext(ctx) + }) + g.POST("/games", adminHandler.CreateGame) + g.GET("/games/:id", adminHandler.GetGame) + g.PUT("/games/:id", adminHandler.UpdateGame) + g.PUT("/games/:id/status", adminHandler.SetGameStatus) + g.POST("/games/:id/reward-configs", auth.RequireLoyaltyManager(), adminHandler.CreateRewardConfig) + g.POST("/reward-configs/:id/activate", auth.RequireLoyaltyManager(), adminHandler.ActivateRewardConfig) + g.POST("/budgets", auth.RequireLoyaltyManager(), adminHandler.CreateBudget) + g.GET("/budgets/:id/metrics", adminHandler.BudgetMetrics) + } + c := router.Group("/customer/enakgame", func(c *gin.Context) { c.Set("customer_id", customer.String()) }) + c.GET("/games", customerHandler.ListGames) + c.POST("/sessions", customerHandler.StartSession) + c.GET("/sessions/:id", customerHandler.GetSession) + + call := func(method, path, body string, headers ...string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + for i := 0; i+1 < len(headers); i += 2 { + req.Header.Set(headers[i], headers[i+1]) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + var out map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String()) + return rec.Code, out + } + data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) } + + status, body := call(http.MethodPost, "/manager/enakgame/games", `{"name": "Runner", "slug": "runner", "entry_cost": 2, "status": "ACTIVE", + "result_rules": {"max_score": 5000, "outcomes": ["WIN", "LOSE"]}}`) + require.Equal(t, http.StatusOK, status, body) + gameID := data(body)["id"].(string) + assert.Equal(t, map[string]any{"max_score": float64(5000), "outcomes": []any{"WIN", "LOSE"}}, data(body)["result_rules"]) + + status, _ = call(http.MethodPost, "/manager/enakgame/games", `{"name": "X", "slug": "x", "entry_cost": 1, "entry_cots": 2}`) + assert.Equal(t, http.StatusBadRequest, status, "a misspelt field is refused") + status, _ = call(http.MethodPost, "/manager/enakgame/games", `{"name": "X", "slug": "runner", "entry_cost": 1}`) + assert.Equal(t, http.StatusBadRequest, status, "slug in use") + status, _ = call(http.MethodGet, "/manager/enakgame/games/"+uuid.NewString(), "") + assert.Equal(t, http.StatusNotFound, status) + status, _ = call(http.MethodGet, "/manager/enakgame/games/not-a-uuid", "") + assert.Equal(t, http.StatusBadRequest, status) + + // A change keeps the fields it does not send. + status, body = call(http.MethodPut, "/manager/enakgame/games/"+gameID, `{"entry_cost": 3}`) + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 3, data(body)["entry_cost"]) + assert.Equal(t, "runner", data(body)["slug"]) + assert.EqualValues(t, 5000, data(body)["result_rules"].(map[string]any)["max_score"]) + + // Reward configurations and budgets need a loyalty manager. + config := `{"reward_type": "OUTCOME_BASED", "rules": {"outcomes": {"WIN": 10, "LOSE": 1}}, "max_reward": 10}` + status, _ = call(http.MethodPost, "/purchasing/enakgame/games/"+gameID+"/reward-configs", config) + assert.Equal(t, http.StatusForbidden, status) + status, body = call(http.MethodPost, "/manager/enakgame/games/"+gameID+"/reward-configs", config) + require.Equal(t, http.StatusOK, status, body) + status, body = call(http.MethodPost, "/manager/enakgame/reward-configs/"+data(body)["id"].(string)+"/activate", "") + require.Equal(t, http.StatusOK, status, body) + assert.Equal(t, "ACTIVE", data(body)["status"]) + + // The customer cannot start before the period has a budget. + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ + CustomerID: customer, Currency: "COIN", Type: "MIGRATION", Amount: 10, + ReferenceType: "LEGACY_TOKENS", ReferenceID: uuid.New(), Description: "Saldo awal"}}) + return err + })) + start := `{"game_id": "` + gameID + `"}` + status, body = call(http.MethodPost, "/customer/enakgame/sessions", start, "Idempotency-Key", "k1") + assert.Equal(t, http.StatusBadRequest, status, body) + now := time.Now().In(time.FixedZone("WIB", 7*3600)) + first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + budget := `{"scope": "GLOBAL", "name": "Bulan ini", "period_start": "` + first.Format("2006-01-02") + + `", "period_end": "` + first.AddDate(0, 1, -1).Format("2006-01-02") + `", "amount": 1000000}` + status, _ = call(http.MethodPost, "/purchasing/enakgame/budgets", budget) + assert.Equal(t, http.StatusForbidden, status) + status, body = call(http.MethodPost, "/manager/enakgame/budgets", budget) + require.Equal(t, http.StatusOK, status, body) + status, body = call(http.MethodGet, "/purchasing/enakgame/budgets/"+data(body)["id"].(string)+"/metrics", "") + require.Equal(t, http.StatusOK, status, body) + assert.Equal(t, "HEALTHY", data(body)["status"]) + assert.EqualValues(t, 1000000, data(body)["remaining"]) + status, _ = call(http.MethodGet, "/manager/enakgame/budgets/"+uuid.NewString()+"/metrics", "") + assert.Equal(t, http.StatusNotFound, status) + + status, body = call(http.MethodGet, "/customer/enakgame/games", "") + require.Equal(t, http.StatusOK, status, body) + listed := body["data"].([]any) + require.Len(t, listed, 1) + assert.NotContains(t, listed[0], "result_rules", "the validation limits stay internal") + + status, body = call(http.MethodPost, "/customer/enakgame/sessions", start) + assert.Equal(t, http.StatusBadRequest, status, "Idempotency-Key is required") + status, body = call(http.MethodPost, "/customer/enakgame/sessions", start, "Idempotency-Key", "k1") + require.Equal(t, http.StatusOK, status, body) + sessionID := data(body)["session_id"].(string) + assert.EqualValues(t, 7, data(body)["coin_balance"]) + status, body = call(http.MethodPost, "/customer/enakgame/sessions", start, "X-Idempotency-Key", "k1") + require.Equal(t, http.StatusOK, status, body) + assert.Equal(t, sessionID, data(body)["session_id"]) + assert.Equal(t, true, data(body)["replayed"]) + status, body = call(http.MethodPost, "/customer/enakgame/sessions", `{}`, "Idempotency-Key", "k2") + assert.Equal(t, http.StatusBadRequest, status, body) + + status, body = call(http.MethodGet, "/customer/enakgame/sessions/"+sessionID, "") + require.Equal(t, http.StatusOK, status, body) + assert.Equal(t, "STARTED", data(body)["status"]) + status, _ = call(http.MethodGet, "/customer/enakgame/sessions/"+uuid.NewString(), "") + assert.Equal(t, http.StatusNotFound, status) + + // The backend alone decides the reward (P1): a reward in the body is ignored. + c.POST("/sessions/:id/complete", customerHandler.CompleteSession) + status, body = call(http.MethodPost, "/customer/enakgame/sessions/"+sessionID+"/complete", `{"outcome": "LOSE", "reward": 999, "reward_total": 999}`) + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 1, data(body)["reward_total"]) + assert.EqualValues(t, 8, data(body)["coin_balance"]) + status, body = call(http.MethodPost, "/customer/enakgame/sessions/"+sessionID+"/complete", `{"outcome": "WIN"}`) + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 1, data(body)["reward_total"], "the first completion stands") + status, _ = call(http.MethodPost, "/customer/enakgame/sessions/"+sessionID+"/complete", `{"score": "high"}`) + assert.Equal(t, http.StatusBadRequest, status) + status, _ = call(http.MethodPost, "/customer/enakgame/sessions/"+uuid.NewString()+"/complete", ``) + assert.Equal(t, http.StatusNotFound, status) + + // Deactivating the game is audited; the session waits for the job. + status, body = call(http.MethodPut, "/manager/enakgame/games/"+gameID+"/status", `{"status": "INACTIVE", "reason": "bug"}`) + require.Equal(t, http.StatusOK, status, body) + status, _ = call(http.MethodPost, "/customer/enakgame/sessions", start, "Idempotency-Key", "k3") + assert.Equal(t, http.StatusBadRequest, status, "an inactive game cannot start") +} + +// Drives the voucher endpoints over HTTP down to Postgres (docs/rfc-enakgame.md §7.4, +// §11). +func TestEnakGameVoucherEndpoints_AgainstPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + applogger.Setup("fatal", "json") + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + + org, admin, customer := uuid.New(), uuid.New(), uuid.New() + hash, err := bcrypt.GenerateFromPassword([]byte("482913"), bcrypt.MinCost) + require.NoError(t, err) + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'voucher http', 'basic')`, org).Error) + require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, pin_hash, pin_set_at) VALUES (?, ?, 'Budi', ?, NOW())`, customer, org, string(hash)).Error) + t.Cleanup(func() { + for _, q := range []string{ + `DELETE FROM audit_logs WHERE organization_id = ?`, + `DELETE FROM voucher_redemption_costs WHERE redemption_id IN (SELECT id FROM voucher_redemptions WHERE organization_id = ?)`, + `DELETE FROM voucher_redemptions WHERE organization_id = ?`, + `DELETE FROM voucher_codes WHERE voucher_id IN (SELECT id FROM vouchers WHERE organization_id = ?)`, + `DELETE FROM vouchers WHERE organization_id = ?`, + `DELETE FROM customer_security_events WHERE customer_id IN (SELECT id FROM customers WHERE organization_id = ?)`, + `DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, + `DELETE FROM wallet_lots WHERE organization_id = ?`, + `DELETE FROM wallet_transactions WHERE organization_id = ?`, + `DELETE FROM customer_wallets WHERE organization_id = ?`, + `DELETE FROM customers WHERE organization_id = ?`, + `DELETE FROM organizations WHERE id = ?`, + } { + db.Exec(q, org) + } + }) + txm := repository.NewTxManager(db) + wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ + CustomerID: customer, Currency: "POINT", Type: "MIGRATION", Amount: 50_000, + ReferenceType: "LEGACY_POINTS", ReferenceID: uuid.New(), Description: "Saldo awal"}}) + return err + })) + + adminHandler, customerHandler := enakGameHandlers(db) + auth := middleware.NewAuthMiddleware(nil) + gin.SetMode(gin.TestMode) + router := gin.New() + for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} { + role := role + g := router.Group(prefix+"/enakgame", func(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String()) + ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String()) + ctx = context.WithValue(ctx, appcontext.UserRoleKey, role) + c.Request = c.Request.WithContext(ctx) + }) + g.POST("/vouchers", auth.RequireLoyaltyManager(), adminHandler.CreateVoucher) + g.PUT("/vouchers/:id", auth.RequireLoyaltyManager(), adminHandler.UpdateVoucher) + g.POST("/vouchers/:id/codes", auth.RequireLoyaltyManager(), adminHandler.ImportVoucherCodes) + g.GET("/vouchers/:id/codes", adminHandler.ListVoucherCodes) + } + c := router.Group("/customer/enakgame", func(c *gin.Context) { c.Set("customer_id", customer.String()) }) + c.GET("/vouchers", customerHandler.ListVouchers) + c.POST("/vouchers/:id/redeem", customerHandler.RedeemVoucher) + c.GET("/redemptions", customerHandler.ListRedemptions) + + send := func(req *http.Request) (int, map[string]any) { + t.Helper() + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + var out map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String()) + return rec.Code, out + } + call := func(method, path, body string, headers ...string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + for i := 0; i+1 < len(headers); i += 2 { + req.Header.Set(headers[i], headers[i+1]) + } + return send(req) + } + data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) } + + voucher := `{"name": "Kopi", "voucher_type": "FREE_ITEM", "face_value": 20000, "point_cost": 15000, "stock_mode": "CODE_POOL", "status": "ACTIVE"}` + status, _ := call(http.MethodPost, "/purchasing/enakgame/vouchers", voucher) + assert.Equal(t, http.StatusForbidden, status) + status, body := call(http.MethodPost, "/manager/enakgame/vouchers", voucher) + require.Equal(t, http.StatusOK, status, body) + id := data(body)["id"].(string) + status, _ = call(http.MethodPut, "/manager/enakgame/vouchers/"+id, `{"stock_mode": "STATIC", "stock": 5}`) + assert.Equal(t, http.StatusBadRequest, status, "the stock mode stays") + + // Codes as a multipart file, then the same as a plain body. + var form bytes.Buffer + w := multipart.NewWriter(&form) + part, err := w.CreateFormFile("file", "codes.csv") + require.NoError(t, err) + _, _ = part.Write([]byte("code\nKOPI-1\nKOPI-2\n")) + require.NoError(t, w.Close()) + req := httptest.NewRequest(http.MethodPost, "/manager/enakgame/vouchers/"+id+"/codes", &form) + req.Header.Set("Content-Type", w.FormDataContentType()) + status, body = send(req) + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 2, data(body)["imported"]) + status, body = call(http.MethodPost, "/manager/enakgame/vouchers/"+id+"/codes", "KOPI-2\nKOPI-3\n", "Content-Type", "text/csv") + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 1, data(body)["imported"]) + assert.Equal(t, []any{"KOPI-2"}, data(body)["duplicates"]) + status, body = call(http.MethodGet, "/manager/enakgame/vouchers/"+id+"/codes", "") + require.Equal(t, http.StatusOK, status, body) + assert.Equal(t, map[string]any{"AVAILABLE": float64(3)}, data(body)["counts"]) + + status, body = call(http.MethodGet, "/customer/enakgame/vouchers", "") + require.Equal(t, http.StatusOK, status, body) + require.Len(t, body["data"], 1) + assert.EqualValues(t, 3, body["data"].([]any)[0].(map[string]any)["available"]) + + redeem := "/customer/enakgame/vouchers/" + id + "/redeem" + status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`) + assert.Equal(t, http.StatusBadRequest, status, "Idempotency-Key is required") + status, body = call(http.MethodPost, redeem, `{}`, "Idempotency-Key", "r1") + assert.Equal(t, http.StatusBadRequest, status, "the PIN is required") + status, body = call(http.MethodPost, redeem, `{"pin": "000000"}`, "Idempotency-Key", "r1") + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "PIN_INVALID", body["errors"].([]any)[0].(map[string]any)["code"]) + status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r1") + require.Equal(t, http.StatusOK, status, body) + code := data(body)["code"] + assert.NotEmpty(t, code) + assert.EqualValues(t, 35_000, data(body)["point_balance"]) + status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r1") + require.Equal(t, http.StatusOK, status, body) + assert.Equal(t, code, data(body)["code"]) + assert.Equal(t, true, data(body)["replayed"]) + status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r2") + require.Equal(t, http.StatusOK, status, body) + status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r3") + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 5_000, data(body)["point_balance"]) + status, _ = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r5") + assert.Equal(t, http.StatusBadRequest, status, "out of codes and of EnakPoint") + status, _ = call(http.MethodPost, "/customer/enakgame/vouchers/"+uuid.NewString()+"/redeem", `{"pin": "482913"}`, "Idempotency-Key", "r4") + assert.Equal(t, http.StatusNotFound, status) + + status, body = call(http.MethodGet, "/customer/enakgame/redemptions", "") + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 3, data(body)["pagination"].(map[string]any)["total_count"]) +} + +// Drives /marketing/enakgame/events over HTTP, and the events on the customer's game +// list (docs/rfc-enakgame.md §11). +func TestEnakGameEventEndpoints_AgainstPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + applogger.Setup("fatal", "json") + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + org, admin, customer := uuid.New(), uuid.New(), uuid.New() + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'event http', 'basic')`, org).Error) + require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org).Error) + t.Cleanup(func() { + for _, q := range []string{ + `DELETE FROM audit_logs WHERE organization_id = ?`, + `DELETE FROM game_event_games WHERE event_id IN (SELECT id FROM game_events WHERE organization_id = ?)`, + `DELETE FROM game_events WHERE organization_id = ?`, + `DELETE FROM game_budgets WHERE organization_id = ?`, + `DELETE FROM games WHERE organization_id = ?`, + `DELETE FROM customers WHERE organization_id = ?`, + `DELETE FROM organizations WHERE id = ?`, + } { + db.Exec(q, org) + } + }) + adminHandler, customerHandler := enakGameHandlers(db) + auth := middleware.NewAuthMiddleware(nil) + gin.SetMode(gin.TestMode) + router := gin.New() + for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} { + role := role + g := router.Group(prefix+"/enakgame", func(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String()) + ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String()) + ctx = context.WithValue(ctx, appcontext.UserRoleKey, role) + c.Request = c.Request.WithContext(ctx) + }) + g.POST("/games", adminHandler.CreateGame) + g.POST("/budgets", auth.RequireLoyaltyManager(), adminHandler.CreateBudget) + g.POST("/events", auth.RequireLoyaltyManager(), adminHandler.CreateEvent) + g.PUT("/events/:id", auth.RequireLoyaltyManager(), adminHandler.UpdateEvent) + g.GET("/events", adminHandler.ListEvents) + } + router.GET("/customer/enakgame/games", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerHandler.ListGames) + call := func(method, path, body string) (int, map[string]any) { + t.Helper() + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(method, path, bytes.NewBufferString(body))) + var out map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String()) + return rec.Code, out + } + data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) } + + status, body := call(http.MethodPost, "/manager/enakgame/games", `{"name": "Runner", "slug": "runner", "entry_cost": 1, "status": "ACTIVE"}`) + require.Equal(t, http.StatusOK, status, body) + gameID := data(body)["id"].(string) + status, body = call(http.MethodPost, "/manager/enakgame/budgets", `{"scope": "EVENT", "name": "Ramadan", "period_start": "2026-01-01", "period_end": "2030-12-31", "amount": 5000000}`) + require.Equal(t, http.StatusOK, status, body) + budgetID := data(body)["id"].(string) + + start := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + end := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339) + event := `{"name": "Ramadan", "slug": "ramadan", "start_at": "` + start + `", "end_at": "` + end + `", "multiplier": 2, + "budget_id": "` + budgetID + `", "game_ids": ["` + gameID + `"], "status": "ACTIVE"}` + status, _ = call(http.MethodPost, "/purchasing/enakgame/events", event) + assert.Equal(t, http.StatusForbidden, status) + status, body = call(http.MethodPost, "/manager/enakgame/events", event) + require.Equal(t, http.StatusOK, status, body) + eventID := data(body)["id"].(string) + status, _ = call(http.MethodPut, "/manager/enakgame/events/"+eventID, `{"multipler": 3}`) + assert.Equal(t, http.StatusBadRequest, status, "a misspelt field is refused") + status, body = call(http.MethodPut, "/manager/enakgame/events/"+eventID, `{"bonus": 5}`) + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 2, data(body)["multiplier"], "kept") + assert.EqualValues(t, 5, data(body)["bonus"]) + status, body = call(http.MethodGet, "/purchasing/enakgame/events?status=active", "") + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 1, data(body)["pagination"].(map[string]any)["total_count"]) + + status, body = call(http.MethodGet, "/customer/enakgame/games", "") + require.Equal(t, http.StatusOK, status, body) + games := body["data"].([]any) + require.Len(t, games, 1) + events := games[0].(map[string]any)["events"].([]any) + require.Len(t, events, 1) + assert.Equal(t, "Ramadan", events[0].(map[string]any)["name"]) + assert.EqualValues(t, 5, events[0].(map[string]any)["bonus"]) + assert.NotContains(t, events[0], "budget_id", "the budget stays internal") +} diff --git a/internal/handler/loyalty_settings_org_db_test.go b/internal/handler/loyalty_settings_org_db_test.go index 6b455d5..c7d3f70 100644 --- a/internal/handler/loyalty_settings_org_db_test.go +++ b/internal/handler/loyalty_settings_org_db_test.go @@ -105,6 +105,7 @@ func TestOrganizationLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) { assert.Equal(t, map[string]any{"coin_amount": float64(1), "point_amount": float64(1)}, got["exchange"]) assert.Equal(t, true, got["transfer"].(map[string]any)["enabled"]) assert.Equal(t, "MONTH", got["point_expiry"].(map[string]any)["unit"]) + assert.Equal(t, map[string]any{"user_daily_limit": float64(0), "global_daily_limit": float64(0)}, got["enakgame"], "0 is no limit") impact := got["impact"].(map[string]any) assert.EqualValues(t, 50000, impact["outstanding_points"]) assert.EqualValues(t, impact["point_rupiah_before"], impact["point_rupiah_after"]) @@ -177,4 +178,17 @@ func TestOrganizationLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) { status, body = call(http.MethodGet, "/manager/loyalty-settings", "") require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 100, data(body)["point_value"]) + + // The EnakGame limits (docs/rfc-enakgame.md §5.10) go through the same endpoint and + // history. + status, body = call(http.MethodPut, "/manager/loyalty-settings", `{"enakgame": {"user_daily_limit": 50, "global_daily_limit": 10000}}`) + require.Equal(t, http.StatusOK, status, body) + assert.Equal(t, map[string]any{"user_daily_limit": float64(50), "global_daily_limit": float64(10000)}, data(body)["enakgame"]) + var limits []struct{ Key, NewValue string } + require.NoError(t, db.Raw(`SELECT key, new_value FROM loyalty_setting_changes WHERE organization_id = ? AND key LIKE 'enakgame.%' ORDER BY key`, org).Scan(&limits).Error) + assert.Equal(t, []struct{ Key, NewValue string }{{"enakgame.limit.global_daily", "10000"}, {"enakgame.limit.user_daily", "50"}}, limits) + status, _ = call(http.MethodPut, "/manager/loyalty-settings", `{"enakgame": {"user_daily_limit": -1}}`) + assert.Equal(t, http.StatusBadRequest, status) + status, _ = call(http.MethodPut, "/purchasing/loyalty-settings", `{"enakgame": {"user_daily_limit": 1}}`) + assert.Equal(t, http.StatusForbidden, status) } diff --git a/internal/models/enakgame.go b/internal/models/enakgame.go new file mode 100644 index 0000000..affa1fb --- /dev/null +++ b/internal/models/enakgame.go @@ -0,0 +1,435 @@ +package models + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/entities" +) + +// EnakGameInput is what an admin sends to create or change a game +// (docs/rfc-enakgame.md §11). On a change, fields left out keep their value. +type EnakGameInput struct { + Name string `json:"name"` + // SPIN, RAFFLE or MINIGAME; MINIGAME when left out on create. + Type string `json:"type"` + Slug string `json:"slug"` + Description *string `json:"description"` + ThumbnailURL *string `json:"thumbnail_url"` + GameURL *string `json:"game_url"` + Version *string `json:"version"` + // Create only: DRAFT (default), ACTIVE or INACTIVE. Changed later through the + // status endpoint. + Status string `json:"status"` + EntryCost int64 `json:"entry_cost"` + // 600 when left out on create. + SessionTTLSeconds int `json:"session_ttl_seconds"` + ResultRules entities.GameResultRules `json:"result_rules"` +} + +type EnakGame struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Slug string `json:"slug"` + Description *string `json:"description"` + ThumbnailURL *string `json:"thumbnail_url"` + GameURL *string `json:"game_url"` + Version *string `json:"version"` + Status string `json:"status"` + EntryCost int64 `json:"entry_cost"` + SessionTTLSeconds int `json:"session_ttl_seconds"` + ResultRules entities.GameResultRules `json:"result_rules"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// EnakGameListQuery filters the admin game list. ARCHIVED games are left out unless +// asked for by status. +type EnakGameListQuery struct { + Status string `form:"status"` + Search string `form:"search"` + Page int `form:"page"` + Limit int `form:"limit"` +} + +type EnakGameStatusInput struct { + Status string `json:"status"` + Reason *string `json:"reason"` +} + +// GameRewardConfigInput is a new version of a game's reward configuration (§5.2). +type GameRewardConfigInput struct { + RewardType string `json:"reward_type"` + Rules json.RawMessage `json:"rules"` + MaxReward int64 `json:"max_reward"` + EffectiveAt *time.Time `json:"effective_at"` + Reason *string `json:"reason"` +} + +type GameRewardConfigActivateInput struct { + Reason *string `json:"reason"` +} + +type GameRewardConfig struct { + ID uuid.UUID `json:"id"` + GameID uuid.UUID `json:"game_id"` + Version int `json:"version"` + RewardType string `json:"reward_type"` + Rules json.RawMessage `json:"rules"` + MaxReward int64 `json:"max_reward"` + Status string `json:"status"` + EffectiveAt *time.Time `json:"effective_at"` + CreatedBy uuid.UUID `json:"created_by"` + Reason *string `json:"reason"` + CreatedAt time.Time `json:"created_at"` +} + +// GameBudgetThresholds are percents of utilization or forecast (PRD §8, §32). +type GameBudgetThresholds struct { + Warning *int64 `json:"warning,omitempty"` + Critical *int64 `json:"critical,omitempty"` +} + +// GameBudgetInput creates or changes a budget (§5.6). Dates are YYYY-MM-DD and +// inclusive. On a change, fields left out keep their value; the scope never changes. +type GameBudgetInput struct { + Scope string `json:"scope"` + Name string `json:"name"` + PeriodStart string `json:"period_start"` + PeriodEnd string `json:"period_end"` + Amount int64 `json:"amount"` + Thresholds GameBudgetThresholds `json:"thresholds"` +} + +type GameBudget struct { + ID uuid.UUID `json:"id"` + Scope string `json:"scope"` + Name string `json:"name"` + PeriodStart string `json:"period_start"` + PeriodEnd string `json:"period_end"` + Amount int64 `json:"amount"` + Thresholds GameBudgetThresholds `json:"thresholds"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type GameBudgetListQuery struct { + Scope string `form:"scope"` + Page int `form:"page"` + Limit int `form:"limit"` +} + +// CustomerEnakGame is a game as the customer app sees it: what it costs and where to +// load it, without the validation limits. +type CustomerEnakGame struct { + ID uuid.UUID `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description *string `json:"description"` + ThumbnailURL *string `json:"thumbnail_url"` + GameURL *string `json:"game_url"` + Version *string `json:"version"` + EntryCost int64 `json:"entry_cost"` + SessionTTLSeconds int `json:"session_ttl_seconds"` + // The events making the game pay more right now, highest priority first. + Events []CustomerGameEvent `json:"events"` +} + +// GameSessionStart is the response of starting a game (§7.1). +type GameSessionStart struct { + SessionID uuid.UUID `json:"session_id"` + GameID uuid.UUID `json:"game_id"` + EntryCost int64 `json:"entry_cost"` + ExpiresAt time.Time `json:"expires_at"` + CoinBalance int64 `json:"coin_balance"` + // True when this repeats an earlier start with the same Idempotency-Key. + Replayed bool `json:"replayed"` +} + +// CustomerGameSession is a session as its customer sees it. The reward breakdown, +// the RNG draw and whether it was flagged stay internal. +type CustomerGameSession struct { + ID uuid.UUID `json:"id"` + GameID uuid.UUID `json:"game_id"` + Status string `json:"status"` + EntryCost int64 `json:"entry_cost"` + RewardTotal int64 `json:"reward_total"` + StartedAt time.Time `json:"started_at"` + ExpiresAt time.Time `json:"expires_at"` + EndedAt *time.Time `json:"ended_at"` + RefundReason *string `json:"refund_reason"` +} + +// GameSessionCompleteInput is what the client reports at the end of a play (§7.2): data +// only. Anything else it sends, a reward amount above all, is ignored (P1). +type GameSessionCompleteInput struct { + Score *int64 `json:"score"` + Outcome *string `json:"outcome"` + Data json.RawMessage `json:"data"` +} + +// GameSessionCompletion is the response of completing a session. Sending the same +// completion again returns the same response. +type GameSessionCompletion struct { + SessionID uuid.UUID `json:"session_id"` + // COMPLETED, or REFUNDED when the game was turned off during the play. + Status string `json:"status"` + RefundReason *string `json:"refund_reason,omitempty"` + RewardTotal int64 `json:"reward_total"` + // The parts of the reward that are safe to show. + Reward GameSessionRewardParts `json:"reward"` + CoinBalance int64 `json:"coin_balance"` + // The daily limits that made the reward smaller than earned: USER_DAILY, + // GAME_DAILY or GLOBAL_DAILY. + LimitedBy []string `json:"limited_by,omitempty"` +} + +type GameSessionRewardParts struct { + Base int64 `json:"base"` + // Added by events; 0 until events exist. + Event int64 `json:"event"` +} + +// VoucherInput creates or changes a voucher (§5.7). On a change, fields left out keep +// their value; the stock mode never changes. +type VoucherInput struct { + Name string `json:"name"` + Description *string `json:"description"` + ImageURL *string `json:"image_url"` + VoucherType string `json:"voucher_type"` + FaceValue int64 `json:"face_value"` + PointCost int64 `json:"point_cost"` + BusinessCost *int64 `json:"business_cost"` + StockMode string `json:"stock_mode"` + // STATIC only. + Stock *int64 `json:"stock"` + // EXTERNAL only. + Provider *string `json:"provider"` + ProviderRef *string `json:"provider_ref"` + MaxPerCustomer *int `json:"max_per_customer"` + ValidFrom *time.Time `json:"valid_from"` + ValidUntil *time.Time `json:"valid_until"` + Terms json.RawMessage `json:"terms"` + // Create only: DRAFT (default), ACTIVE or INACTIVE. + Status string `json:"status"` +} + +type Voucher struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + ImageURL *string `json:"image_url"` + VoucherType string `json:"voucher_type"` + FaceValue int64 `json:"face_value"` + PointCost int64 `json:"point_cost"` + BusinessCost *int64 `json:"business_cost"` + StockMode string `json:"stock_mode"` + Stock *int64 `json:"stock"` + Provider *string `json:"provider"` + ProviderRef *string `json:"provider_ref"` + MaxPerCustomer *int `json:"max_per_customer"` + ValidFrom *time.Time `json:"valid_from"` + ValidUntil *time.Time `json:"valid_until"` + Terms json.RawMessage `json:"terms"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type VoucherListQuery struct { + Status string `form:"status"` + Search string `form:"search"` + Page int `form:"page"` + Limit int `form:"limit"` +} + +type VoucherStatusInput struct { + Status string `json:"status"` + Reason *string `json:"reason"` +} + +// VoucherCodeImportResult says what an import of codes did. +type VoucherCodeImportResult struct { + Imported int `json:"imported"` + // Codes already in the pool or repeated in the file, skipped. + DuplicateCount int `json:"duplicate_count"` + Duplicates []string `json:"duplicates"` + // Lines that could not be read, skipped. + Invalid []VoucherCodeImportProblem `json:"invalid"` +} + +type VoucherCodeImportProblem struct { + Line int `json:"line"` + Reason string `json:"reason"` +} + +type VoucherCodeListQuery struct { + Status string `form:"status"` + Page int `form:"page"` + Limit int `form:"limit"` +} + +type VoucherCode struct { + ID uuid.UUID `json:"id"` + Code string `json:"code"` + Status string `json:"status"` + RedemptionID *uuid.UUID `json:"redemption_id"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +// VoucherCodes is a pool's codes: how many in each status, and one page of them. +type VoucherCodes struct { + Counts map[string]int64 `json:"counts"` + Codes PaginatedResponse[VoucherCode] `json:"codes"` +} + +// CustomerVoucher is a voucher in the customer app's catalog. Available is how many +// are left, without saying how many were redeemed or expired. +type CustomerVoucher struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + ImageURL *string `json:"image_url"` + VoucherType string `json:"voucher_type"` + FaceValue int64 `json:"face_value"` + PointCost int64 `json:"point_cost"` + MaxPerCustomer *int `json:"max_per_customer"` + ValidUntil *time.Time `json:"valid_until"` + Terms json.RawMessage `json:"terms"` + Available *int64 `json:"available"` +} + +// CustomerVoucherRedemption is a redemption as its customer sees it, with the code to +// use. The response of a redeem adds the EnakPoint balance. +type CustomerVoucherRedemption struct { + ID uuid.UUID `json:"id"` + VoucherID uuid.UUID `json:"voucher_id"` + VoucherName string `json:"voucher_name"` + VoucherImageURL *string `json:"voucher_image_url"` + VoucherType string `json:"voucher_type"` + Status string `json:"status"` + FaceValue int64 `json:"face_value"` + PointCost int64 `json:"point_cost"` + Code *string `json:"code"` + CodeExpiresAt *time.Time `json:"code_expires_at"` + CompletedAt *time.Time `json:"completed_at"` + CreatedAt time.Time `json:"created_at"` +} + +type VoucherRedeemResult struct { + CustomerVoucherRedemption + PointBalance int64 `json:"point_balance"` + // True when this repeats an earlier redeem with the same Idempotency-Key. + Replayed bool `json:"replayed"` +} + +// GameBudgetMetrics is how a budget stands (docs/rfc-enakgame.md §10, PRD §8). Money is +// in rupiah, percents have two decimals. +type GameBudgetMetrics struct { + BudgetID uuid.UUID `json:"budget_id"` + Scope string `json:"scope"` + PeriodStart string `json:"period_start"` + PeriodEnd string `json:"period_end"` + // The day, in Asia/Jakarta, the metrics are for. + AsOf string `json:"as_of"` + Amount int64 `json:"amount"` + + RealizedCost int64 `json:"realized_cost"` + Remaining int64 `json:"remaining"` + UtilizationPercent float64 `json:"utilization_percent"` + + // Average realized cost per day over the last WindowDays days, today included. + DailyBurn int64 `json:"daily_burn"` + WindowDays int64 `json:"window_days"` + // Days of the period after today. + RemainingDays int64 `json:"remaining_days"` + ForecastCost int64 `json:"forecast_cost"` + ForecastRemaining int64 `json:"forecast_remaining"` + ForecastUtilizationPercent float64 `json:"forecast_utilization_percent"` + + CoinIssued int64 `json:"coin_issued"` + // What the budget's rewards still hold, spendable: the most that can still turn + // into cost. + Exposure GameBudgetExposure `json:"exposure"` + + // The thresholds used: the budget's own, or the defaults for those it does not set. + Thresholds GameBudgetThresholds `json:"thresholds"` + // HEALTHY, WARNING, CRITICAL or EXHAUSTED. + Status string `json:"status"` +} + +type GameBudgetExposure struct { + Coins int64 `json:"coins"` + Points int64 `json:"points"` +} + +// GameEventInput creates or changes an event (§5.5). On a change, fields left out +// keep their value. +type GameEventInput struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description"` + BannerURL *string `json:"banner_url"` + StartAt time.Time `json:"start_at"` + EndAt time.Time `json:"end_at"` + // Asia/Jakarta when left out. + Timezone string `json:"timezone"` + Priority int `json:"priority"` + // At least 1, two decimals at most. 2 adds the base reward once more. + Multiplier *float64 `json:"multiplier"` + Bonus *int64 `json:"bonus"` + // An EVENT budget of the organization; it pays what the event adds. + BudgetID uuid.UUID `json:"budget_id"` + RewardLimit *int64 `json:"reward_limit"` + UserDailyLimit *int64 `json:"user_daily_limit"` + GameIDs []uuid.UUID `json:"game_ids"` + // Create only: DRAFT (default) or ACTIVE. + Status string `json:"status"` +} + +type GameEvent struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description"` + BannerURL *string `json:"banner_url"` + StartAt time.Time `json:"start_at"` + EndAt time.Time `json:"end_at"` + Timezone string `json:"timezone"` + Status string `json:"status"` + Priority int `json:"priority"` + Multiplier *float64 `json:"multiplier"` + Bonus *int64 `json:"bonus"` + BudgetID uuid.UUID `json:"budget_id"` + RewardLimit *int64 `json:"reward_limit"` + UserDailyLimit *int64 `json:"user_daily_limit"` + GameIDs []uuid.UUID `json:"game_ids"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type GameEventListQuery struct { + Status string `form:"status"` + Page int `form:"page"` + Limit int `form:"limit"` +} + +type GameEventStatusInput struct { + Status string `json:"status"` + Reason *string `json:"reason"` +} + +// CustomerGameEvent is an event running on a game, as the customer app shows it. +type CustomerGameEvent struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + BannerURL *string `json:"banner_url"` + Multiplier *float64 `json:"multiplier"` + Bonus *int64 `json:"bonus"` + EndAt time.Time `json:"end_at"` +} diff --git a/internal/models/loyalty.go b/internal/models/loyalty.go index 336692e..d349ffc 100644 --- a/internal/models/loyalty.go +++ b/internal/models/loyalty.go @@ -44,6 +44,16 @@ type OrganizationLoyaltySettings struct { Transfer LoyaltyTransferSettings `json:"transfer"` PointExpiry LoyaltyExpirySettings `json:"point_expiry"` CoinExpiry LoyaltyExpirySettings `json:"coin_expiry"` + EnakGame EnakGameLimitSettings `json:"enakgame"` +} + +// EnakGameLimitSettings caps the EnakCoin EnakGame rewards per day, in Asia/Jakarta +// (docs/rfc-enakgame.md §5.10). 0 means no limit. +type EnakGameLimitSettings struct { + // What one customer may receive. + UserDailyLimit int64 `json:"user_daily_limit"` + // What the whole organization may give out. + GlobalDailyLimit int64 `json:"global_daily_limit"` } type LoyaltyExchangeSettings struct { diff --git a/internal/processor/audit_logger.go b/internal/processor/audit_logger.go new file mode 100644 index 0000000..76fae0a --- /dev/null +++ b/internal/processor/audit_logger.go @@ -0,0 +1,103 @@ +package processor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/repository" +) + +// ErrInvalidAuditEntry means an audit entry lacks what every row must say. +var ErrInvalidAuditEntry = errors.New("invalid audit entry") + +// AuditEntry is one change to record. Before and After are marshalled to JSON; leave +// one nil when there was nothing before (a create) or after (a delete). +type AuditEntry struct { + OrganizationID uuid.UUID + // USER or SYSTEM. A USER entry needs ActorID. + ActorType string + ActorID *uuid.UUID + EntityType string + EntityID uuid.UUID + Action string + Before any + After any + Reason *string + Source string +} + +// AuditLogger writes audit_logs (docs/rfc-enakgame.md §13). It must be called inside +// the transaction that makes the change, so the change and its row commit or roll +// back together: outside one it returns repository.ErrAuditTxRequired. +type AuditLogger struct { + repo repository.AuditLogRepository +} + +func NewAuditLogger(repo repository.AuditLogRepository) *AuditLogger { + return &AuditLogger{repo: repo} +} + +func (l *AuditLogger) Record(ctx context.Context, e AuditEntry) error { + invalid := func(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalidAuditEntry, fmt.Sprintf(format, args...)) + } + switch { + case e.OrganizationID == uuid.Nil: + return invalid("organization is required") + case e.ActorType != constants.AuditActorUser && e.ActorType != constants.AuditActorSystem: + return invalid("unknown actor type %q", e.ActorType) + case e.ActorType == constants.AuditActorUser && isNilID(e.ActorID): + return invalid("a USER entry requires the actor") + case strings.TrimSpace(e.EntityType) == "" || e.EntityID == uuid.Nil: + return invalid("entity is required") + case strings.TrimSpace(e.Action) == "": + return invalid("action is required") + case strings.TrimSpace(e.Source) == "": + return invalid("source is required") + } + before, err := auditJSON(e.Before) + if err != nil { + return invalid("before: %v", err) + } + after, err := auditJSON(e.After) + if err != nil { + return invalid("after: %v", err) + } + return l.repo.Insert(ctx, &entities.AuditLog{ + OrganizationID: e.OrganizationID, + ActorType: e.ActorType, + ActorID: e.ActorID, + EntityType: e.EntityType, + EntityID: e.EntityID, + Action: e.Action, + Before: before, + After: after, + Reason: e.Reason, + Source: e.Source, + }) +} + +func auditJSON(v any) (json.RawMessage, error) { + if v == nil { + return nil, nil + } + raw, ok := v.(json.RawMessage) + if !ok { + var err error + if raw, err = json.Marshal(v); err != nil { + return nil, err + } + } + // A nil pointer is nothing, the same as nil. + if string(raw) == "null" { + return nil, nil + } + return raw, nil +} diff --git a/internal/processor/audit_logger_test.go b/internal/processor/audit_logger_test.go new file mode 100644 index 0000000..6b20ec2 --- /dev/null +++ b/internal/processor/audit_logger_test.go @@ -0,0 +1,129 @@ +package processor + +import ( + "context" + "os" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/repository" +) + +type auditRepoFake struct{ rows []entities.AuditLog } + +func (f *auditRepoFake) Insert(_ context.Context, e *entities.AuditLog) error { + f.rows = append(f.rows, *e) + return nil +} + +func (f *auditRepoFake) ListByEntity(context.Context, uuid.UUID, string, uuid.UUID, int) ([]entities.AuditLog, error) { + return f.rows, nil +} + +func validAuditEntry() AuditEntry { + return AuditEntry{ + OrganizationID: uuid.New(), + ActorType: constants.AuditActorUser, + ActorID: ptr(uuid.New()), + EntityType: constants.AuditEntityGame, + EntityID: uuid.New(), + Action: "STATUS_CHANGED", + Before: map[string]string{"status": "DRAFT"}, + After: map[string]string{"status": "ACTIVE"}, + Source: constants.AuditSourceAdminAPI, + } +} + +func TestAuditLogger_RecordsSnapshotsAsJSON(t *testing.T) { + repo := &auditRepoFake{} + l := NewAuditLogger(repo) + require.NoError(t, l.Record(context.Background(), validAuditEntry())) + + created := validAuditEntry() + created.ActorType, created.ActorID = constants.AuditActorSystem, nil + var nothing *entities.Game + created.Before = nothing + require.NoError(t, l.Record(context.Background(), created)) + + require.Len(t, repo.rows, 2) + assert.JSONEq(t, `{"status": "DRAFT"}`, string(repo.rows[0].Before)) + assert.JSONEq(t, `{"status": "ACTIVE"}`, string(repo.rows[0].After)) + assert.Nil(t, repo.rows[1].Before, "a nil pointer is stored as NULL, not as JSON null") +} + +func TestAuditLogger_RejectsIncompleteEntries(t *testing.T) { + for name, mutate := range map[string]func(*AuditEntry){ + "no organization": func(e *AuditEntry) { e.OrganizationID = uuid.Nil }, + "unknown actor type": func(e *AuditEntry) { e.ActorType = "ROBOT" }, + "USER without actor": func(e *AuditEntry) { e.ActorID = nil }, + "no entity type": func(e *AuditEntry) { e.EntityType = " " }, + "no entity id": func(e *AuditEntry) { e.EntityID = uuid.Nil }, + "no action": func(e *AuditEntry) { e.Action = "" }, + "no source": func(e *AuditEntry) { e.Source = "" }, + "unmarshallable": func(e *AuditEntry) { e.After = func() {} }, + } { + repo := &auditRepoFake{} + e := validAuditEntry() + mutate(&e) + err := NewAuditLogger(repo).Record(context.Background(), e) + assert.ErrorIs(t, err, ErrInvalidAuditEntry, name) + assert.Empty(t, repo.rows, name) + } +} + +// An audit row commits and rolls back with the change it records. Needs +// TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go. +func TestAuditLogger_AgainstPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + org := uuid.New() + t.Cleanup(func() { db.Exec(`DELETE FROM audit_logs WHERE organization_id = ?`, org) }) + + repo := repository.NewAuditLogRepository(db) + l := NewAuditLogger(repo) + txm := repository.NewTxManager(db) + entry := func(entityID uuid.UUID) AuditEntry { + e := validAuditEntry() + e.OrganizationID, e.EntityID, e.Reason = org, entityID, ptr("promo") + return e + } + ctx := context.Background() + + committed, rolledBack := uuid.New(), uuid.New() + require.NoError(t, txm.WithTransaction(ctx, func(ctx context.Context) error { + return l.Record(ctx, entry(committed)) + })) + failure := assert.AnError + err = txm.WithTransaction(ctx, func(ctx context.Context) error { + require.NoError(t, l.Record(ctx, entry(rolledBack))) + return failure + }) + require.ErrorIs(t, err, failure) + + rows, err := repo.ListByEntity(ctx, org, constants.AuditEntityGame, committed, 10) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "STATUS_CHANGED", rows[0].Action) + assert.JSONEq(t, `{"status": "DRAFT"}`, string(rows[0].Before)) + assert.JSONEq(t, `{"status": "ACTIVE"}`, string(rows[0].After)) + assert.Equal(t, "promo", *rows[0].Reason) + assert.False(t, rows[0].CreatedAt.IsZero()) + + rows, err = repo.ListByEntity(ctx, org, constants.AuditEntityGame, rolledBack, 10) + require.NoError(t, err) + assert.Empty(t, rows, "a rolled back change leaves no audit row") + + assert.ErrorIs(t, l.Record(ctx, entry(uuid.New())), repository.ErrAuditTxRequired) +} diff --git a/internal/processor/customer_pin_processor.go b/internal/processor/customer_pin_processor.go index 7143735..51bc000 100644 --- a/internal/processor/customer_pin_processor.go +++ b/internal/processor/customer_pin_processor.go @@ -45,6 +45,8 @@ const ( PinActionPay PinAction = "PAY" PinActionExchange PinAction = "EXCHANGE" PinActionTransfer PinAction = "TRANSFER" + // Redeeming EnakPoint for an EnakGame voucher (docs/rfc-enakgame.md §7.4). + PinActionRedeem PinAction = "REDEEM" ) type pinVerifier interface { diff --git a/internal/processor/customer_pin_processor_test.go b/internal/processor/customer_pin_processor_test.go index f99a593..77d7a59 100644 --- a/internal/processor/customer_pin_processor_test.go +++ b/internal/processor/customer_pin_processor_test.go @@ -105,3 +105,35 @@ func TestCustomerPin_LockIsPushedThroughFCM(t *testing.T) { assert.Equal(t, "PIN EnakPoint kamu terkunci sampai 30 Sep 2026 10:30 WIB karena salah dimasukkan 5 kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.", push.body) assert.Equal(t, map[string]string{"type": NotificationTypePinLocked, "locked_until": "2026-09-30T03:30:00Z"}, push.data) } + +// EG-503: redeeming a voucher follows the same PIN rules: five wrong attempts lock +// it for 30 minutes, and a locked PIN is refused even when right. A transfer hold +// after a reset does not apply to it. +func TestCustomerPin_RedeemFollowsTheLockRules(t *testing.T) { + customer := uuid.New() + hash, err := bcrypt.GenerateFromPassword([]byte("482913"), bcrypt.MinCost) + require.NoError(t, err) + h := string(hash) + now := time.Date(2026, 10, 7, 3, 0, 0, 0, time.UTC) + held := now.Add(24 * time.Hour) + repo := &pinRepoFake{state: repository.CustomerPinState{CustomerID: customer, PinHash: &h, TransferBlockedUntil: &held}} + p := NewCustomerPinProcessor(repo, nil, ¬ifierFake{}) + p.now = func() time.Time { return now } + ctx := context.Background() + + require.NoError(t, p.VerifyPin(ctx, customer, "482913", PinActionRedeem, models.CustomerPinRequestInfo{}), "not held like a transfer") + var pinErr *PinError + for i := 1; i <= 4; i++ { + err := p.VerifyPin(ctx, customer, "000000", PinActionRedeem, models.CustomerPinRequestInfo{}) + require.ErrorAs(t, err, &pinErr) + assert.Equal(t, PinErrInvalid, pinErr.Code) + assert.Equal(t, 5-i, pinErr.RemainingAttempts) + } + err = p.VerifyPin(ctx, customer, "000000", PinActionRedeem, models.CustomerPinRequestInfo{}) + require.ErrorAs(t, err, &pinErr) + assert.Equal(t, PinErrLocked, pinErr.Code) + assert.Equal(t, now.Add(30*time.Minute), *pinErr.Until) + err = p.VerifyPin(ctx, customer, "482913", PinActionRedeem, models.CustomerPinRequestInfo{}) + require.ErrorAs(t, err, &pinErr) + assert.Equal(t, PinErrLocked, pinErr.Code, "locked even with the right PIN") +} diff --git a/internal/processor/enakgame_admin_processor.go b/internal/processor/enakgame_admin_processor.go new file mode 100644 index 0000000..e7e54ef --- /dev/null +++ b/internal/processor/enakgame_admin_processor.go @@ -0,0 +1,462 @@ +package processor + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// ErrEnakGameRejected wraps every reason an EnakGame request is refused: a field out +// of bounds, a slug in use, an archived game, a retired configuration. The message +// says which. +var ErrEnakGameRejected = errors.New("enakgame request refused") + +const ( + enakGameDefaultSessionTTL = 600 + enakGameMaxSessionTTL = 24 * 60 * 60 + enakGamePageLimit = 20 + enakGameMaxPageLimit = 100 +) + +var enakGameSlugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +func enakGameRejected(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrEnakGameRejected, fmt.Sprintf(format, args...)) +} + +// EnakGameAdminProcessor manages an organization's EnakGame games and their reward +// configurations (docs/rfc-enakgame.md §5.1, §5.2, §11). Every change is written to +// audit_logs in the same transaction (§13). +type EnakGameAdminProcessor struct { + games repository.EnakGameRepository + audit *AuditLogger + tx TxRunner +} + +func NewEnakGameAdminProcessor(games repository.EnakGameRepository, audit *AuditLogger, tx TxRunner) *EnakGameAdminProcessor { + return &EnakGameAdminProcessor{games: games, audit: audit, tx: tx} +} + +func (p *EnakGameAdminProcessor) record(ctx context.Context, organizationID, actor uuid.UUID, entityType string, entityID uuid.UUID, action string, before, after any, reason *string) error { + return p.audit.Record(ctx, AuditEntry{ + OrganizationID: organizationID, + ActorType: constants.AuditActorUser, + ActorID: &actor, + EntityType: entityType, + EntityID: entityID, + Action: action, + Before: before, + After: after, + Reason: reason, + Source: constants.AuditSourceAdminAPI, + }) +} + +// EnakGameInputFrom is a game's current values as an input, for a change that only +// sends the fields it changes. +func EnakGameInputFrom(game *models.EnakGame) models.EnakGameInput { + return models.EnakGameInput{ + Name: game.Name, Type: game.Type, Slug: game.Slug, Description: game.Description, + ThumbnailURL: game.ThumbnailURL, GameURL: game.GameURL, Version: game.Version, Status: game.Status, + EntryCost: game.EntryCost, SessionTTLSeconds: game.SessionTTLSeconds, ResultRules: game.ResultRules, + } +} + +func (p *EnakGameAdminProcessor) CreateGame(ctx context.Context, organizationID, actor uuid.UUID, in models.EnakGameInput) (*models.EnakGame, error) { + if in.Type == "" { + in.Type = string(entities.GameTypeMinigame) + } + if in.Status == "" { + in.Status = constants.GameStatusDraft + } + if in.SessionTTLSeconds == 0 { + in.SessionTTLSeconds = enakGameDefaultSessionTTL + } + if err := validateEnakGameInput(&in); err != nil { + return nil, err + } + switch in.Status { + case constants.GameStatusDraft, constants.GameStatusActive, constants.GameStatusInactive: + default: + return nil, enakGameRejected("a new game must be DRAFT, ACTIVE or INACTIVE") + } + + game := &entities.Game{OrganizationID: &organizationID, Status: in.Status} + applyEnakGameInput(game, in) + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if err := p.games.CreateGame(ctx, game); err != nil { + return err + } + return p.record(ctx, organizationID, actor, constants.AuditEntityGame, game.ID, "CREATED", nil, enakGameModel(game), nil) + }) + if err != nil { + return nil, enakGameError(err) + } + return enakGameModel(game), nil +} + +func (p *EnakGameAdminProcessor) GetGame(ctx context.Context, organizationID, id uuid.UUID) (*models.EnakGame, error) { + game, err := p.games.GetGame(ctx, organizationID, id) + if err != nil { + return nil, err + } + return enakGameModel(game), nil +} + +func (p *EnakGameAdminProcessor) ListGames(ctx context.Context, organizationID uuid.UUID, q models.EnakGameListQuery) (*models.PaginatedResponse[models.EnakGame], error) { + page, limit := enakGamePage(q.Page, q.Limit) + statuses := []string{constants.GameStatusDraft, constants.GameStatusActive, constants.GameStatusInactive} + if q.Status != "" { + status := strings.ToUpper(strings.TrimSpace(q.Status)) + if !isEnakGameStatus(status) { + return nil, enakGameRejected("unknown status %q", q.Status) + } + statuses = []string{status} + } + games, total, err := p.games.ListGames(ctx, repository.EnakGameFilter{ + OrganizationID: organizationID, Statuses: statuses, Search: strings.TrimSpace(q.Search), + Offset: (page - 1) * limit, Limit: limit, + }) + if err != nil { + return nil, err + } + items := make([]models.EnakGame, 0, len(games)) + for i := range games { + items = append(items, *enakGameModel(&games[i])) + } + return &models.PaginatedResponse[models.EnakGame]{Data: items, Pagination: enakGamePagination(page, limit, total)}, nil +} + +// UpdateGame changes everything but the status, which has its own endpoint. An +// archived game never changes. +func (p *EnakGameAdminProcessor) UpdateGame(ctx context.Context, organizationID, actor, id uuid.UUID, in models.EnakGameInput) (*models.EnakGame, error) { + if err := validateEnakGameInput(&in); err != nil { + return nil, err + } + var after *models.EnakGame + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + game, err := p.games.LockGame(ctx, organizationID, id) + if err != nil { + return err + } + if game.Status == constants.GameStatusArchived { + return enakGameRejected("an archived game cannot change") + } + before := enakGameModel(game) + applyEnakGameInput(game, in) + if err := p.games.UpdateGame(ctx, game); err != nil { + return err + } + after = enakGameModel(game) + return p.record(ctx, organizationID, actor, constants.AuditEntityGame, id, "UPDATED", before, after, nil) + }) + if err != nil { + return nil, enakGameError(err) + } + return p.GetGame(ctx, organizationID, after.ID) +} + +// SetGameStatus moves a game between DRAFT, ACTIVE and INACTIVE, or archives it for +// good. Sessions still open on a game leaving ACTIVE are refunded by the session job +// (§7.3), not here. +func (p *EnakGameAdminProcessor) SetGameStatus(ctx context.Context, organizationID, actor, id uuid.UUID, in models.EnakGameStatusInput) (*models.EnakGame, error) { + status := strings.ToUpper(strings.TrimSpace(in.Status)) + if !isEnakGameStatus(status) { + return nil, enakGameRejected("status must be DRAFT, ACTIVE, INACTIVE or ARCHIVED") + } + if err := validateReason(in.Reason); err != nil { + return nil, err + } + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + game, err := p.games.LockGame(ctx, organizationID, id) + if err != nil { + return err + } + if game.Status == status { + return nil + } + if game.Status == constants.GameStatusArchived { + return enakGameRejected("an archived game cannot change") + } + if err := p.games.SetGameStatus(ctx, organizationID, id, status); err != nil { + return err + } + return p.record(ctx, organizationID, actor, constants.AuditEntityGame, id, "STATUS_CHANGED", + map[string]string{"status": game.Status}, map[string]string{"status": status}, in.Reason) + }) + if err != nil { + return nil, enakGameError(err) + } + return p.GetGame(ctx, organizationID, id) +} + +// CreateRewardConfig stores a new DRAFT version of a game's reward configuration. A +// configuration is never edited (D7): a change is a new version, activated on its +// own. +func (p *EnakGameAdminProcessor) CreateRewardConfig(ctx context.Context, organizationID, actor, gameID uuid.UUID, in models.GameRewardConfigInput) (*models.GameRewardConfig, error) { + rewardType := strings.ToUpper(strings.TrimSpace(in.RewardType)) + calculator, err := RewardCalculatorFor(rewardType) + if err != nil { + return nil, enakGameRejected("%v", err) + } + if len(in.Rules) == 0 { + return nil, enakGameRejected("rules are required") + } + if err := calculator.Validate(in.Rules); err != nil { + return nil, enakGameRejected("%v", err) + } + if in.MaxReward < 0 { + return nil, enakGameRejected("max_reward must be 0 or more") + } + if err := validateReason(in.Reason); err != nil { + return nil, err + } + var rules bytes.Buffer + if err := json.Compact(&rules, in.Rules); err != nil { + return nil, enakGameRejected("rules: %v", err) + } + + config := &entities.GameRewardConfig{ + OrganizationID: organizationID, GameID: gameID, RewardType: rewardType, + Rules: entities.JSONDocument(rules.Bytes()), MaxReward: in.MaxReward, + Status: constants.GameRewardConfigStatusDraft, EffectiveAt: in.EffectiveAt, CreatedBy: actor, Reason: in.Reason, + } + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + // The game lock also orders versions of the same game one after the other. + game, err := p.games.LockGame(ctx, organizationID, gameID) + if err != nil { + return err + } + if game.Status == constants.GameStatusArchived { + return enakGameRejected("an archived game cannot change") + } + if err := p.games.CreateRewardConfig(ctx, config); err != nil { + return err + } + return p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, config.ID, "CREATED", + nil, rewardConfigModel(config), in.Reason) + }) + if err != nil { + return nil, enakGameError(err) + } + return rewardConfigModel(config), nil +} + +// ActivateRewardConfig makes a DRAFT version the game's active configuration and +// retires the one active before, in one transaction. Activating the active version +// again changes nothing. A retired version stays retired: going back means a new +// version with the old rules. +func (p *EnakGameAdminProcessor) ActivateRewardConfig(ctx context.Context, organizationID, actor, id uuid.UUID, in models.GameRewardConfigActivateInput) (*models.GameRewardConfig, error) { + if err := validateReason(in.Reason); err != nil { + return nil, err + } + var activated *entities.GameRewardConfig + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + config, err := p.games.GetRewardConfig(ctx, organizationID, id) + if err != nil { + return err + } + // Activations of the same game wait for each other here, so exactly one + // version ends up active. + game, err := p.games.LockGame(ctx, organizationID, config.GameID) + if err != nil { + return err + } + if game.Status == constants.GameStatusArchived { + return enakGameRejected("an archived game cannot change") + } + if config, err = p.games.GetRewardConfig(ctx, organizationID, id); err != nil { + return err + } + activated = config + switch config.Status { + case constants.GameRewardConfigStatusActive: + return nil + case constants.GameRewardConfigStatusRetired: + return enakGameRejected("version %d is retired; create a new version instead", config.Version) + } + + current, err := p.games.GetActiveRewardConfig(ctx, organizationID, config.GameID) + if err != nil && !errors.Is(err, repository.ErrGameRewardConfigNotFound) { + return err + } + if current != nil { + if _, err := p.moveRewardConfig(ctx, organizationID, actor, current, constants.GameRewardConfigStatusRetired, "RETIRED", in.Reason); err != nil { + return err + } + } + activated, err = p.moveRewardConfig(ctx, organizationID, actor, config, constants.GameRewardConfigStatusActive, "ACTIVATED", in.Reason) + return err + }) + if err != nil { + return nil, enakGameError(err) + } + return rewardConfigModel(activated), nil +} + +func (p *EnakGameAdminProcessor) moveRewardConfig(ctx context.Context, organizationID, actor uuid.UUID, config *entities.GameRewardConfig, to, action string, reason *string) (*entities.GameRewardConfig, error) { + moved, err := p.games.SetRewardConfigStatus(ctx, organizationID, config.ID, config.Status, to) + if err != nil { + return nil, err + } + if !moved { + return nil, enakGameRejected("version %d changed meanwhile; try again", config.Version) + } + before := config.Status + next := *config + next.Status = to + err = p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, config.ID, action, + map[string]string{"status": before}, map[string]string{"status": to}, reason) + return &next, err +} + +// ListRewardConfigs returns every version of a game's configuration, newest first. +func (p *EnakGameAdminProcessor) ListRewardConfigs(ctx context.Context, organizationID, gameID uuid.UUID) ([]models.GameRewardConfig, error) { + if _, err := p.games.GetGame(ctx, organizationID, gameID); err != nil { + return nil, err + } + configs, err := p.games.ListRewardConfigs(ctx, organizationID, gameID) + if err != nil { + return nil, err + } + out := make([]models.GameRewardConfig, 0, len(configs)) + for i := range configs { + out = append(out, *rewardConfigModel(&configs[i])) + } + return out, nil +} + +// enakGameError turns a slug collision into a refusal; anything else passes as is. +func enakGameError(err error) error { + if errors.Is(err, repository.ErrEnakGameSlugTaken) { + return enakGameRejected("another game already uses this slug") + } + return err +} + +func isEnakGameStatus(s string) bool { + switch s { + case constants.GameStatusDraft, constants.GameStatusActive, constants.GameStatusInactive, constants.GameStatusArchived: + return true + } + return false +} + +func validateReason(reason *string) error { + if reason != nil && len(*reason) > 255 { + return enakGameRejected("reason must be at most 255 characters") + } + return nil +} + +func validateEnakGameInput(in *models.EnakGameInput) error { + in.Name = strings.TrimSpace(in.Name) + in.Slug = strings.TrimSpace(in.Slug) + in.Type = strings.ToUpper(strings.TrimSpace(in.Type)) + in.Status = strings.ToUpper(strings.TrimSpace(in.Status)) + switch { + case in.Name == "" || len(in.Name) > 255: + return enakGameRejected("name is required, at most 255 characters") + case in.Type != string(entities.GameTypeSpin) && in.Type != string(entities.GameTypeRaffle) && in.Type != string(entities.GameTypeMinigame): + return enakGameRejected("type must be SPIN, RAFFLE or MINIGAME") + case len(in.Slug) > 100 || !enakGameSlugPattern.MatchString(in.Slug): + return enakGameRejected("slug must be lowercase letters, digits and single dashes, at most 100 characters") + case in.EntryCost < 1: + // PRD §10.1: no game is free. + return enakGameRejected("entry_cost must be at least 1") + case in.SessionTTLSeconds < 1 || in.SessionTTLSeconds > enakGameMaxSessionTTL: + return enakGameRejected("session_ttl_seconds must be between 1 and %d", enakGameMaxSessionTTL) + } + for field, value := range map[string]*string{"thumbnail_url": in.ThumbnailURL, "game_url": in.GameURL} { + if value != nil && len(*value) > 500 { + return enakGameRejected("%s must be at most 500 characters", field) + } + } + if in.Version != nil && len(*in.Version) > 50 { + return enakGameRejected("version must be at most 50 characters") + } + return validateResultRules(in.ResultRules) +} + +func validateResultRules(r entities.GameResultRules) error { + switch { + case r.MaxScore != nil && *r.MaxScore < 0: + return enakGameRejected("result_rules.max_score must be 0 or more") + case r.MinDurationSeconds != nil && *r.MinDurationSeconds < 0: + return enakGameRejected("result_rules.min_duration_seconds must be 0 or more") + case r.MaxScorePerSecond != nil && !(*r.MaxScorePerSecond > 0): + return enakGameRejected("result_rules.max_score_per_second must be more than 0") + case r.DailyRewardLimit != nil && *r.DailyRewardLimit < 0: + return enakGameRejected("result_rules.daily_reward_limit must be 0 or more") + } + seen := map[string]bool{} + for _, outcome := range r.Outcomes { + if strings.TrimSpace(outcome) == "" { + return enakGameRejected("result_rules.outcomes cannot hold an empty outcome") + } + if seen[outcome] { + return enakGameRejected("result_rules.outcomes lists %q twice", outcome) + } + seen[outcome] = true + } + return nil +} + +func applyEnakGameInput(game *entities.Game, in models.EnakGameInput) { + slug := in.Slug + game.Name = in.Name + game.Type = entities.GameType(in.Type) + game.Slug = &slug + game.Description = in.Description + game.ThumbnailURL = in.ThumbnailURL + game.GameURL = in.GameURL + game.Version = in.Version + game.EntryCost = in.EntryCost + game.SessionTTLSeconds = in.SessionTTLSeconds + game.ResultRules = in.ResultRules +} + +func enakGameModel(g *entities.Game) *models.EnakGame { + m := &models.EnakGame{ + ID: g.ID, Name: g.Name, Type: string(g.Type), Description: g.Description, ThumbnailURL: g.ThumbnailURL, + GameURL: g.GameURL, Version: g.Version, Status: g.Status, EntryCost: g.EntryCost, + SessionTTLSeconds: g.SessionTTLSeconds, ResultRules: g.ResultRules, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, + } + if g.Slug != nil { + m.Slug = *g.Slug + } + return m +} + +func rewardConfigModel(c *entities.GameRewardConfig) *models.GameRewardConfig { + return &models.GameRewardConfig{ + ID: c.ID, GameID: c.GameID, Version: c.Version, RewardType: c.RewardType, Rules: json.RawMessage(c.Rules), + MaxReward: c.MaxReward, Status: c.Status, EffectiveAt: c.EffectiveAt, CreatedBy: c.CreatedBy, + Reason: c.Reason, CreatedAt: c.CreatedAt, + } +} + +func enakGamePage(page, limit int) (int, int) { + if page < 1 { + page = 1 + } + if limit < 1 || limit > enakGameMaxPageLimit { + limit = enakGamePageLimit + } + return page, limit +} + +func enakGamePagination(page, limit int, total int64) models.Pagination { + return models.Pagination{Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit))} +} diff --git a/internal/processor/enakgame_db_test.go b/internal/processor/enakgame_db_test.go new file mode 100644 index 0000000..dab2f87 --- /dev/null +++ b/internal/processor/enakgame_db_test.go @@ -0,0 +1,974 @@ +package processor + +import ( + "context" + "encoding/json" + "os" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// EnakGame admin, start, refund and the session job against Postgres +// (docs/rfc-enakgame.md §7.1, §7.3, §11). Needs TEST_DATABASE_URL; see +// internal/repository/wallet_repository_test.go. + +type enakGameEnv struct { + t *testing.T + db *gorm.DB + admin *EnakGameAdminProcessor + budgets *GameBudgetProcessor + sessions *GameSessionProcessor + events *GameEventProcessor + vouchers *VoucherAdminProcessor + redeem *VoucherRedemptionProcessor + exchange *WalletExchangeProcessor + provider *fakeVoucherProvider + wallet *WalletProcessor + txm *repository.TxManager + orgA uuid.UUID + orgB uuid.UUID + alice uuid.UUID // customer of A + bob uuid.UUID // customer of A + carol uuid.UUID // customer of B + manager uuid.UUID + now time.Time +} + +func newEnakGameEnv(t *testing.T) *enakGameEnv { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + e := &enakGameEnv{ + t: t, db: db, txm: repository.NewTxManager(db), + orgA: uuid.New(), orgB: uuid.New(), alice: uuid.New(), bob: uuid.New(), carol: uuid.New(), manager: uuid.New(), + now: time.Now(), + } + orgs := []uuid.UUID{e.orgA, e.orgB} + for _, org := range orgs { + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame test', 'basic')`, org).Error) + } + require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Alice'), (?, ?, 'Bob'), (?, ?, 'Carol')`, + e.alice, e.orgA, e.bob, e.orgA, e.carol, e.orgB).Error) + t.Cleanup(func() { + for _, q := range []string{ + `DELETE FROM audit_logs WHERE organization_id IN ?`, + `DELETE FROM voucher_redemption_costs WHERE redemption_id IN (SELECT id FROM voucher_redemptions WHERE organization_id IN ?)`, + `DELETE FROM voucher_redemptions WHERE organization_id IN ?`, + `DELETE FROM voucher_codes WHERE voucher_id IN (SELECT id FROM vouchers WHERE organization_id IN ?)`, + `DELETE FROM vouchers WHERE organization_id IN ?`, + `DELETE FROM customer_security_events WHERE customer_id IN (SELECT id FROM customers WHERE organization_id IN ?)`, + `DELETE FROM game_reward_counters WHERE organization_id IN ?`, + `DELETE FROM game_session_rewards WHERE session_id IN (SELECT id FROM game_sessions WHERE organization_id IN ?)`, + `DELETE FROM game_sessions WHERE organization_id IN ?`, + `DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id IN ?)`, + `DELETE FROM wallet_lots WHERE organization_id IN ?`, + `DELETE FROM wallet_transactions WHERE organization_id IN ?`, + `DELETE FROM customer_wallets WHERE organization_id IN ?`, + `DELETE FROM game_event_games WHERE event_id IN (SELECT id FROM game_events WHERE organization_id IN ?)`, + `DELETE FROM game_events WHERE organization_id IN ?`, + `DELETE FROM game_reward_configs WHERE organization_id IN ?`, + `DELETE FROM game_budgets WHERE organization_id IN ?`, + `DELETE FROM games WHERE organization_id IN ?`, + `DELETE FROM customers WHERE organization_id IN ?`, + `DELETE FROM organizations WHERE id IN ?`, + } { + db.Exec(q, orgs) + } + }) + + audit := NewAuditLogger(repository.NewAuditLogRepository(db)) + games := repository.NewEnakGameRepository(db) + budgets := repository.NewGameBudgetRepository(db) + e.wallet = NewWalletProcessor(repository.NewWalletRepository(db)) + e.admin = NewEnakGameAdminProcessor(games, audit, e.txm) + e.budgets = NewGameBudgetProcessor(budgets, audit, e.txm) + e.sessions = NewGameSessionProcessor(repository.NewWalletMoveRepository(db), games, repository.NewGameSessionRepository(db), + budgets, repository.NewGameEventRepository(db), repository.NewGameRewardCounterRepository(db), NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), e.txm), repository.NewWalletQueryRepository(db), e.wallet, audit, e.txm) + settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), e.txm) + pins := NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), nil, nil) + vouchers := repository.NewVoucherRepository(db) + e.provider = &fakeVoucherProvider{issued: map[uuid.UUID]*VoucherIssue{}} + e.events = NewGameEventProcessor(repository.NewGameEventRepository(db), games, budgets, audit, e.txm) + e.vouchers = NewVoucherAdminProcessor(vouchers, audit, e.txm) + e.redeem = NewVoucherRedemptionProcessor(repository.NewWalletMoveRepository(db), vouchers, repository.NewVoucherRedemptionRepository(db), + pins, repository.NewWalletQueryRepository(db), e.wallet, VoucherProviders{"acme": e.provider}, e.txm) + e.exchange = NewWalletExchangeProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db), pins, e.wallet, e.txm) + hash, err := bcrypt.GenerateFromPassword([]byte(testPin), bcrypt.MinCost) + require.NoError(t, err) + require.NoError(t, db.Exec(`UPDATE customers SET pin_hash = ?, pin_set_at = NOW() WHERE organization_id IN ?`, string(hash), orgs).Error) + + clock := func() time.Time { return e.now } + e.budgets.now, e.sessions.now, e.wallet.now, e.redeem.now, e.exchange.now = clock, clock, clock, clock, clock + return e +} + +// testPin is every test customer's PIN. +const testPin = "482913" + +func (e *enakGameEnv) ctx() context.Context { return context.Background() } + +// coins gives a customer EnakCoin in one lot expiring at expiresAt (nil: never). +func (e *enakGameEnv) coins(customer uuid.UUID, amount int64, expiresAt *time.Time) { + e.t.Helper() + require.NoError(e.t, e.txm.WithTransaction(e.ctx(), func(ctx context.Context) error { + _, err := e.wallet.Credit(ctx, WalletCreditInput{ + WalletEntry: WalletEntry{ + CustomerID: customer, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration, + Amount: amount, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: uuid.New(), Description: "Saldo awal", + }, + Lots: []WalletLotInput{{Amount: amount, ExpiresAt: expiresAt}}, + }) + return err + })) +} + +func (e *enakGameEnv) coinBalance(customer uuid.UUID) int64 { + e.t.Helper() + var balance int64 + require.NoError(e.t, e.db.Raw(`SELECT COALESCE((SELECT coin_balance FROM customer_wallets WHERE customer_id = ?), 0)`, customer).Scan(&balance).Error) + return balance +} + +func (e *enakGameEnv) count(query string, args ...any) int64 { + e.t.Helper() + var n int64 + require.NoError(e.t, e.db.Raw(query, args...).Scan(&n).Error) + return n +} + +// playableGame makes an ACTIVE game of org with an active FIXED config, and a global +// budget for the month if there is none. +func (e *enakGameEnv) playableGame(org uuid.UUID, slug string, entryCost int64) *models.EnakGame { + e.t.Helper() + game, err := e.admin.CreateGame(e.ctx(), org, e.manager, models.EnakGameInput{ + Name: "Game " + slug, Slug: slug, Status: constants.GameStatusActive, EntryCost: entryCost, + }) + require.NoError(e.t, err) + config, err := e.admin.CreateRewardConfig(e.ctx(), org, e.manager, game.ID, models.GameRewardConfigInput{ + RewardType: constants.GameRewardTypeFixed, Rules: json.RawMessage(`{"amount": 10}`), MaxReward: 10, + }) + require.NoError(e.t, err) + _, err = e.admin.ActivateRewardConfig(e.ctx(), org, e.manager, config.ID, models.GameRewardConfigActivateInput{}) + require.NoError(e.t, err) + if e.count(`SELECT COUNT(*) FROM game_budgets WHERE organization_id = ? AND scope = 'GLOBAL'`, org) == 0 { + e.globalBudget(org, e.now) + } + return game +} + +func (e *enakGameEnv) globalBudget(org uuid.UUID, day time.Time) *models.GameBudget { + e.t.Helper() + d := day.In(walletDisplayLocation) + start := time.Date(d.Year(), d.Month(), 1, 0, 0, 0, 0, time.UTC) + budget, err := e.budgets.CreateBudget(e.ctx(), org, e.manager, models.GameBudgetInput{ + Scope: constants.GameBudgetScopeGlobal, Name: "Bulanan", Amount: 100_000_000, + PeriodStart: start.Format("2006-01-02"), PeriodEnd: start.AddDate(0, 1, -1).Format("2006-01-02"), + }) + require.NoError(e.t, err) + return budget +} + +func (e *enakGameEnv) auditActions(entityType string, id uuid.UUID) []string { + e.t.Helper() + var actions []string + require.NoError(e.t, e.db.Raw(`SELECT action FROM audit_logs WHERE entity_type = ? AND entity_id = ? ORDER BY created_at, action`, entityType, id).Scan(&actions).Error) + return actions +} + +// EG-201 +func TestEnakGameAdmin_Games(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + + game, err := e.admin.CreateGame(ctx, e.orgA, e.manager, models.EnakGameInput{Name: " Tap Tap ", Slug: "tap-tap", EntryCost: 3}) + require.NoError(t, err) + assert.Equal(t, "Tap Tap", game.Name) + assert.Equal(t, constants.GameStatusDraft, game.Status, "a new game starts as a draft") + assert.Equal(t, "MINIGAME", game.Type) + assert.Equal(t, 600, game.SessionTTLSeconds) + + for name, in := range map[string]models.EnakGameInput{ + "no name": {Slug: "a", EntryCost: 1}, + "bad slug": {Name: "x", Slug: "Tap Tap", EntryCost: 1}, + "free": {Name: "x", Slug: "free", EntryCost: 0}, + "archived on start": {Name: "x", Slug: "arch", EntryCost: 1, Status: "ARCHIVED"}, + "unknown type": {Name: "x", Slug: "t", EntryCost: 1, Type: "PUZZLE"}, + "negative max": {Name: "x", Slug: "m", EntryCost: 1, ResultRules: entities.GameResultRules{MaxScore: ptr(int64(-1))}}, + "same outcome": {Name: "x", Slug: "o", EntryCost: 1, ResultRules: entities.GameResultRules{Outcomes: []string{"WIN", "WIN"}}}, + "slug in use": {Name: "x", Slug: "tap-tap", EntryCost: 1}, + } { + _, err := e.admin.CreateGame(ctx, e.orgA, e.manager, in) + assert.ErrorIs(t, err, ErrEnakGameRejected, name) + } + _, err = e.admin.CreateGame(ctx, e.orgB, e.manager, models.EnakGameInput{Name: "Tap", Slug: "tap-tap", EntryCost: 1}) + require.NoError(t, err, "another organization may use the slug") + + // Organization B sees nothing of A's game. + _, err = e.admin.GetGame(ctx, e.orgB, game.ID) + assert.ErrorIs(t, err, repository.ErrEnakGameNotFound) + _, err = e.admin.UpdateGame(ctx, e.orgB, e.manager, game.ID, EnakGameInputFrom(game)) + assert.ErrorIs(t, err, repository.ErrEnakGameNotFound) + _, err = e.admin.SetGameStatus(ctx, e.orgB, e.manager, game.ID, models.EnakGameStatusInput{Status: "ACTIVE"}) + assert.ErrorIs(t, err, repository.ErrEnakGameNotFound) + list, err := e.admin.ListGames(ctx, e.orgB, models.EnakGameListQuery{}) + require.NoError(t, err) + require.Len(t, list.Data, 1) + assert.NotEqual(t, game.ID, list.Data[0].ID) + + in := EnakGameInputFrom(game) + in.EntryCost = 5 + updated, err := e.admin.UpdateGame(ctx, e.orgA, e.manager, game.ID, in) + require.NoError(t, err) + assert.EqualValues(t, 5, updated.EntryCost) + assert.Equal(t, constants.GameStatusDraft, updated.Status, "an update does not touch the status") + + reason := "launch" + _, err = e.admin.SetGameStatus(ctx, e.orgA, e.manager, game.ID, models.EnakGameStatusInput{Status: "active", Reason: &reason}) + require.NoError(t, err) + var audit struct { + ActorID string + Before string + After string + Reason string + } + require.NoError(t, e.db.Raw(`SELECT actor_id::text AS actor_id, before::text AS before, after::text AS after, reason FROM audit_logs + WHERE entity_id = ? AND action = 'STATUS_CHANGED'`, game.ID).Scan(&audit).Error) + assert.Equal(t, e.manager.String(), audit.ActorID) + assert.JSONEq(t, `{"status": "DRAFT"}`, audit.Before) + assert.JSONEq(t, `{"status": "ACTIVE"}`, audit.After) + assert.Equal(t, "launch", audit.Reason) + + // Archived: hidden from the default list, never changed again. + _, err = e.admin.SetGameStatus(ctx, e.orgA, e.manager, game.ID, models.EnakGameStatusInput{Status: "ARCHIVED"}) + require.NoError(t, err) + _, err = e.admin.SetGameStatus(ctx, e.orgA, e.manager, game.ID, models.EnakGameStatusInput{Status: "ACTIVE"}) + assert.ErrorIs(t, err, ErrEnakGameRejected) + _, err = e.admin.UpdateGame(ctx, e.orgA, e.manager, game.ID, in) + assert.ErrorIs(t, err, ErrEnakGameRejected) + list, err = e.admin.ListGames(ctx, e.orgA, models.EnakGameListQuery{}) + require.NoError(t, err) + assert.Empty(t, list.Data) + list, err = e.admin.ListGames(ctx, e.orgA, models.EnakGameListQuery{Status: "archived"}) + require.NoError(t, err) + assert.Len(t, list.Data, 1) + + assert.Equal(t, []string{"CREATED", "UPDATED", "STATUS_CHANGED", "STATUS_CHANGED"}, e.auditActions(constants.AuditEntityGame, game.ID)) +} + +// EG-202 +func TestEnakGameAdmin_RewardConfigs(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + game, err := e.admin.CreateGame(ctx, e.orgA, e.manager, models.EnakGameInput{Name: "Runner", Slug: "runner", EntryCost: 1}) + require.NoError(t, err) + + newVersion := func(amount int) *models.GameRewardConfig { + t.Helper() + c, err := e.admin.CreateRewardConfig(ctx, e.orgA, e.manager, game.ID, models.GameRewardConfigInput{ + RewardType: "fixed", Rules: json.RawMessage(`{ "amount" : ` + string(rune('0'+amount)) + ` }`), MaxReward: 10, + }) + require.NoError(t, err) + return c + } + v1, v2 := newVersion(8), newVersion(9) + assert.Equal(t, []int{1, 2}, []int{v1.Version, v2.Version}) + assert.Equal(t, constants.GameRewardConfigStatusDraft, v1.Status) + assert.JSONEq(t, `{"amount": 8}`, string(v1.Rules)) + + for name, in := range map[string]models.GameRewardConfigInput{ + "unknown type": {RewardType: "TIERED", Rules: json.RawMessage(`{}`)}, + "no rules": {RewardType: "FIXED"}, + "rules invalid": {RewardType: "SCORE_BASED", Rules: json.RawMessage(`{"bands": [{"min": 5, "amount": 1}]}`)}, + "negative max": {RewardType: "FIXED", Rules: json.RawMessage(`{"amount": 1}`), MaxReward: -1}, + "unknown rule key": {RewardType: "FIXED", Rules: json.RawMessage(`{"amount": 1, "bonus": 2}`)}, + } { + _, err := e.admin.CreateRewardConfig(ctx, e.orgA, e.manager, game.ID, in) + assert.ErrorIs(t, err, ErrEnakGameRejected, name) + } + _, err = e.admin.CreateRewardConfig(ctx, e.orgB, e.manager, game.ID, models.GameRewardConfigInput{RewardType: "FIXED", Rules: json.RawMessage(`{"amount": 1}`)}) + assert.ErrorIs(t, err, repository.ErrEnakGameNotFound, "another organization's game") + _, err = e.admin.ActivateRewardConfig(ctx, e.orgB, e.manager, v1.ID, models.GameRewardConfigActivateInput{}) + assert.ErrorIs(t, err, repository.ErrGameRewardConfigNotFound) + + // Two versions activated at once: exactly one ends up active. + var wg sync.WaitGroup + for _, v := range []*models.GameRewardConfig{v1, v2} { + v := v + wg.Add(1) + go func() { + defer wg.Done() + _, err := e.admin.ActivateRewardConfig(ctx, e.orgA, e.manager, v.ID, models.GameRewardConfigActivateInput{}) + assert.NoError(t, err) + }() + } + wg.Wait() + configs, err := e.admin.ListRewardConfigs(ctx, e.orgA, game.ID) + require.NoError(t, err) + active := 0 + for _, c := range configs { + if c.Status == constants.GameRewardConfigStatusActive { + active++ + } + } + assert.Equal(t, 1, active) + + // Activating the active one again changes nothing; a retired one stays retired. + var retired *models.GameRewardConfig + for i := range configs { + if configs[i].Status == constants.GameRewardConfigStatusRetired { + retired = &configs[i] + } + } + require.NotNil(t, retired, "the version activated first was retired by the second") + _, err = e.admin.ActivateRewardConfig(ctx, e.orgA, e.manager, retired.ID, models.GameRewardConfigActivateInput{}) + assert.ErrorIs(t, err, ErrEnakGameRejected) + + // Nothing but status ever changed: the rules are as created. + for _, c := range configs { + want := map[uuid.UUID]string{v1.ID: `{"amount": 8}`, v2.ID: `{"amount": 9}`}[c.ID] + assert.JSONEq(t, want, string(c.Rules)) + } + assert.Equal(t, int64(2), e.count(`SELECT COUNT(*) FROM audit_logs WHERE entity_type = 'GAME_REWARD_CONFIG' AND action = 'CREATED' AND organization_id = ?`, e.orgA)) + assert.Equal(t, int64(2), e.count(`SELECT COUNT(*) FROM audit_logs WHERE entity_type = 'GAME_REWARD_CONFIG' AND action = 'ACTIVATED' AND organization_id = ?`, e.orgA)) + assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM audit_logs WHERE entity_type = 'GAME_REWARD_CONFIG' AND action = 'RETIRED' AND organization_id = ?`, e.orgA)) +} + +// EG-203 +func TestEnakGameAdmin_Budgets(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + e.now = time.Date(2026, 10, 15, 12, 0, 0, 0, walletDisplayLocation) + october, err := e.budgets.CreateBudget(ctx, e.orgA, e.manager, models.GameBudgetInput{ + Scope: "global", Name: "Oktober", PeriodStart: "2026-10-01", PeriodEnd: "2026-10-31", Amount: 100_000_000, + Thresholds: models.GameBudgetThresholds{Warning: ptr(int64(70)), Critical: ptr(int64(90))}, + }) + require.NoError(t, err) + assert.Equal(t, "2026-10-01", october.PeriodStart) + assert.EqualValues(t, 90, *october.Thresholds.Critical) + + for name, in := range map[string]models.GameBudgetInput{ + "overlaps": {Scope: "GLOBAL", Name: "x", PeriodStart: "2026-10-20", PeriodEnd: "2026-11-19", Amount: 1}, + "same start": {Scope: "GLOBAL", Name: "x", PeriodStart: "2026-10-01", PeriodEnd: "2026-10-01", Amount: 1}, + "end before": {Scope: "GLOBAL", Name: "x", PeriodStart: "2026-12-02", PeriodEnd: "2026-12-01", Amount: 1}, + "no amount": {Scope: "GLOBAL", Name: "x", PeriodStart: "2026-12-01", PeriodEnd: "2026-12-31"}, + "bad date": {Scope: "GLOBAL", Name: "x", PeriodStart: "1/12/2026", PeriodEnd: "2026-12-31", Amount: 1}, + "unknown scope": {Scope: "OUTLET", Name: "x", PeriodStart: "2026-12-01", PeriodEnd: "2026-12-31", Amount: 1}, + "warning > crit": {Scope: "EVENT", Name: "x", PeriodStart: "2026-12-01", PeriodEnd: "2026-12-31", Amount: 1, Thresholds: models.GameBudgetThresholds{Warning: ptr(int64(95)), Critical: ptr(int64(90))}}, + "threshold > 100": {Scope: "EVENT", Name: "x", PeriodStart: "2026-12-01", PeriodEnd: "2026-12-31", Amount: 1, Thresholds: models.GameBudgetThresholds{Critical: ptr(int64(101))}}, + } { + _, err := e.budgets.CreateBudget(ctx, e.orgA, e.manager, in) + assert.ErrorIs(t, err, ErrEnakGameRejected, name) + } + _, err = e.budgets.CreateBudget(ctx, e.orgA, e.manager, models.GameBudgetInput{Scope: "EVENT", Name: "Ramadan", PeriodStart: "2026-10-10", PeriodEnd: "2026-10-20", Amount: 5_000_000}) + require.NoError(t, err, "an event budget may share days with the global one") + + // A change records the amount before and after. + in := GameBudgetInputFrom(october) + in.Amount = 120_000_000 + updated, err := e.budgets.UpdateBudget(ctx, e.orgA, e.manager, october.ID, in) + require.NoError(t, err) + assert.EqualValues(t, 120_000_000, updated.Amount) + var change struct{ Before, After string } + require.NoError(t, e.db.Raw(`SELECT before->>'amount' AS before, after->>'amount' AS after FROM audit_logs WHERE entity_id = ? AND action = 'UPDATED'`, october.ID).Scan(&change).Error) + assert.Equal(t, struct{ Before, After string }{"100000000", "120000000"}, change) + in.Scope = "EVENT" + _, err = e.budgets.UpdateBudget(ctx, e.orgA, e.manager, october.ID, in) + assert.ErrorIs(t, err, ErrEnakGameRejected, "the scope stays") + _, err = e.budgets.UpdateBudget(ctx, e.orgB, e.manager, october.ID, GameBudgetInputFrom(october)) + assert.ErrorIs(t, err, repository.ErrGameBudgetNotFound) + + // The period job opens November once, however often it runs. It works across + // organizations, so this organization's budgets are what is checked. + for run := 0; run < 3; run++ { + _, err := e.budgets.CreateNextPeriods(ctx) + require.NoError(t, err) + assert.Equal(t, int64(2), e.count(`SELECT COUNT(*) FROM game_budgets WHERE organization_id = ? AND scope = 'GLOBAL'`, e.orgA), "run %d", run) + } + list, err := e.budgets.ListBudgets(ctx, e.orgA, models.GameBudgetListQuery{Scope: "GLOBAL"}) + require.NoError(t, err) + require.Len(t, list.Data, 2) + november := list.Data[0] + assert.Equal(t, "2026-11-01", november.PeriodStart) + assert.Equal(t, "2026-11-30", november.PeriodEnd) + assert.EqualValues(t, 120_000_000, november.Amount) + assert.EqualValues(t, 70, *november.Thresholds.Warning) + assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM audit_logs WHERE entity_id = ? AND actor_type = 'SYSTEM'`, november.ID)) + + // A budget a reward points at stays; an unused one goes. + game := e.playableGame(e.orgA, "budget-in-use", 1) + e.coins(e.alice, 5, nil) + started, err := e.sessions.Start(ctx, e.alice, game.ID, "k1") + require.NoError(t, err) + reward := uuid.New() + require.NoError(t, e.db.Exec(`INSERT INTO wallet_transactions (id, organization_id, customer_id, currency, type, amount, balance_after, reference_type, reference_id, description) + VALUES (?, ?, ?, 'COIN', 'GAME_REWARD', 1, 0, 'GAME_SESSION', ?, 'x')`, reward, e.orgA, e.alice, started.SessionID).Error) + require.NoError(t, e.db.Exec(`INSERT INTO game_session_rewards (session_id, budget_id, amount, wallet_transaction_id) VALUES (?, ?, 1, ?)`, + started.SessionID, october.ID, reward).Error) + assert.ErrorIs(t, e.budgets.DeleteBudget(ctx, e.orgA, e.manager, october.ID), ErrEnakGameRejected) + require.NoError(t, e.budgets.DeleteBudget(ctx, e.orgA, e.manager, november.ID)) + assert.Equal(t, []string{"CREATED", "DELETED"}, e.auditActions(constants.AuditEntityGameBudget, november.ID)) +} + +// EG-204 +func TestGameSession_Start(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + game := e.playableGame(e.orgA, "runner", 3) + e.coins(e.alice, 10, nil) + + first, err := e.sessions.Start(ctx, e.alice, game.ID, "tap-1") + require.NoError(t, err) + assert.False(t, first.Replayed) + assert.EqualValues(t, 3, first.EntryCost) + assert.EqualValues(t, 7, first.CoinBalance) + assert.WithinDuration(t, e.now.Add(600*time.Second), first.ExpiresAt, time.Second) + + // The same key again: the same session, nothing more taken. + again, err := e.sessions.Start(ctx, e.alice, game.ID, "tap-1") + require.NoError(t, err) + assert.True(t, again.Replayed) + assert.Equal(t, first.SessionID, again.SessionID) + assert.EqualValues(t, 7, e.coinBalance(e.alice)) + assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, e.alice)) + assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM game_sessions WHERE customer_id = ?`, e.alice)) + var spend struct { + ReferenceType string + ReferenceID string + Amount int64 + } + require.NoError(t, e.db.Raw(`SELECT reference_type, reference_id::text AS reference_id, amount FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, e.alice).Scan(&spend).Error) + assert.Equal(t, constants.WalletRefTypeGameSession, spend.ReferenceType) + assert.Equal(t, first.SessionID.String(), spend.ReferenceID) + assert.EqualValues(t, -3, spend.Amount) + + // The session keeps what it started with. + configs, err := e.admin.ListRewardConfigs(ctx, e.orgA, game.ID) + require.NoError(t, err) + in := EnakGameInputFrom(game) + in.EntryCost = 9 + _, err = e.admin.UpdateGame(ctx, e.orgA, e.manager, game.ID, in) + require.NoError(t, err) + v2, err := e.admin.CreateRewardConfig(ctx, e.orgA, e.manager, game.ID, models.GameRewardConfigInput{RewardType: "FIXED", Rules: json.RawMessage(`{"amount": 1}`)}) + require.NoError(t, err) + _, err = e.admin.ActivateRewardConfig(ctx, e.orgA, e.manager, v2.ID, models.GameRewardConfigActivateInput{}) + require.NoError(t, err) + var snapshot struct { + EntryCost int64 + RewardConfigID string + } + require.NoError(t, e.db.Raw(`SELECT entry_cost, reward_config_id::text AS reward_config_id FROM game_sessions WHERE id = ?`, first.SessionID).Scan(&snapshot).Error) + assert.EqualValues(t, 3, snapshot.EntryCost) + assert.Equal(t, configs[0].ID.String(), snapshot.RewardConfigID) + _, err = e.sessions.Start(ctx, e.alice, uuid.New(), "tap-1") + assert.ErrorIs(t, err, ErrGameSessionRejected, "the key started another game") + + // Refusals leave neither a ledger row nor a session. + inactive := e.playableGame(e.orgA, "inactive", 1) + _, err = e.admin.SetGameStatus(ctx, e.orgA, e.manager, inactive.ID, models.EnakGameStatusInput{Status: "INACTIVE"}) + require.NoError(t, err) + unconfigured, err := e.admin.CreateGame(ctx, e.orgA, e.manager, models.EnakGameInput{Name: "x", Slug: "no-config", EntryCost: 1, Status: "ACTIVE"}) + require.NoError(t, err) + theirs := e.playableGame(e.orgB, "theirs", 1) + e.coins(e.carol, 10, nil) + noBudget, err := e.admin.CreateGame(ctx, e.orgB, e.manager, models.EnakGameInput{Name: "x", Slug: "no-budget", EntryCost: 1, Status: "ACTIVE"}) + require.NoError(t, err) + + for name, tc := range map[string]struct { + customer uuid.UUID + game uuid.UUID + key string + want error + }{ + "not enough EnakCoin": {e.bob, game.ID, "b1", ErrGameSessionRejected}, + "another org's game": {e.alice, theirs.ID, "a2", repository.ErrEnakGameNotFound}, + "inactive game": {e.alice, inactive.ID, "a3", ErrGameSessionRejected}, + "no active config": {e.alice, unconfigured.ID, "a4", ErrGameSessionRejected}, + "no key": {e.alice, game.ID, " ", ErrGameSessionRejected}, + "key too long": {e.alice, game.ID, string(make([]byte, 51)), ErrGameSessionRejected}, + "unknown customer": {uuid.New(), game.ID, "u1", repository.ErrWalletNotFound}, + "unknown game": {e.alice, uuid.New(), "a5", repository.ErrEnakGameNotFound}, + "no budget for period": {e.carol, noBudget.ID, "c1", nil}, + } { + if name == "no budget for period" { + // B has a budget from playableGame; move it out of this period. + require.NoError(t, e.db.Exec(`UPDATE game_budgets SET period_start = '2000-01-01', period_end = '2000-01-31' WHERE organization_id = ?`, e.orgB).Error) + cfg, err := e.admin.CreateRewardConfig(ctx, e.orgB, e.manager, noBudget.ID, models.GameRewardConfigInput{RewardType: "FIXED", Rules: json.RawMessage(`{"amount": 1}`)}) + require.NoError(t, err) + _, err = e.admin.ActivateRewardConfig(ctx, e.orgB, e.manager, cfg.ID, models.GameRewardConfigActivateInput{}) + require.NoError(t, err) + tc.want = ErrGameSessionRejected + } + before := e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'GAME_SPEND' AND organization_id IN (?, ?)`, e.orgA, e.orgB) + _, err := e.sessions.Start(ctx, tc.customer, tc.game, tc.key) + assert.ErrorIs(t, err, tc.want, name) + assert.Equal(t, before, e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'GAME_SPEND' AND organization_id IN (?, ?)`, e.orgA, e.orgB), name) + } + assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM game_sessions WHERE organization_id IN (?, ?)`, e.orgA, e.orgB)) +} + +// EG-205 +func TestGameSession_RefundAndJob(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + game := e.playableGame(e.orgA, "runner", 4) + other := e.playableGame(e.orgA, "other", 4) + soon := e.now.Add(48 * time.Hour) + e.coins(e.alice, 100, &soon) + e.coins(e.bob, 100, nil) + + start := func(customer, gameID uuid.UUID, key string) uuid.UUID { + t.Helper() + s, err := e.sessions.Start(ctx, customer, gameID, key) + require.NoError(t, err) + return s.SessionID + } + deactivated := start(e.alice, game.ID, "a1") + failed := start(e.alice, other.ID, "a2") + abandoned := start(e.bob, other.ID, "b1") + stillOpen := start(e.bob, other.ID, "b2") + require.NoError(t, e.db.Exec(`UPDATE game_sessions SET expires_at = ? WHERE id IN ?`, e.now.Add(-time.Minute), []uuid.UUID{failed, abandoned}).Error) + require.NoError(t, e.db.Exec(`UPDATE game_sessions SET completion_failed_at = ? WHERE id = ?`, e.now.Add(-2*time.Minute), failed).Error) + _, err := e.admin.SetGameStatus(ctx, e.orgA, e.manager, game.ID, models.EnakGameStatusInput{Status: "INACTIVE"}) + require.NoError(t, err) + assert.EqualValues(t, 92, e.coinBalance(e.alice)) + assert.EqualValues(t, 92, e.coinBalance(e.bob)) + + // Run the job twice: the second run finds nothing more to do here. The job works + // across organizations, and other tests may share the database, so what is + // checked is this organization's sessions, not the job's totals. + refunded, expired, err := e.sessions.ProcessDueSessions(ctx) + require.NoError(t, err) + assert.GreaterOrEqual(t, refunded, 2) + assert.GreaterOrEqual(t, expired, 1) + _, _, err = e.sessions.ProcessDueSessions(ctx) + require.NoError(t, err) + + status := func(id uuid.UUID) (string, *string) { + var row struct { + Status string + RefundReason *string + } + require.NoError(t, e.db.Raw(`SELECT status, refund_reason FROM game_sessions WHERE id = ?`, id).Scan(&row).Error) + return row.Status, row.RefundReason + } + s, reason := status(deactivated) + assert.Equal(t, constants.GameSessionStatusRefunded, s, "refunded without waiting for its expiry") + assert.Equal(t, constants.GameSessionRefundGameDeactivated, *reason) + s, reason = status(failed) + assert.Equal(t, constants.GameSessionStatusRefunded, s) + assert.Equal(t, constants.GameSessionRefundSystemError, *reason) + s, reason = status(abandoned) + assert.Equal(t, constants.GameSessionStatusExpired, s, "left behind: no refund") + assert.Nil(t, reason) + s, _ = status(stillOpen) + assert.Equal(t, constants.GameSessionStatusStarted, s) + assert.EqualValues(t, 100, e.coinBalance(e.alice), "both entry costs came back") + assert.EqualValues(t, 92, e.coinBalance(e.bob), "abandoned and open sessions keep their cost") + assert.Equal(t, int64(2), e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND_REFUND'`, e.alice)) + + // The refund lot points at the lot the entry cost came from and lasts at least + // seven days, although that lot expires in two. + var lot struct { + OriginLotID string + ExpiresAt time.Time + Reverses string + } + require.NoError(t, e.db.Raw(` + SELECT l.origin_lot_id::text AS origin_lot_id, l.expires_at, t.reverses_transaction_id::text AS reverses + FROM game_sessions s + JOIN wallet_transactions t ON t.id = s.refund_transaction_id + JOIN wallet_lots l ON l.source_transaction_id = t.id + WHERE s.id = ?`, deactivated).Scan(&lot).Error) + var spendLot, spendTx string + require.NoError(t, e.db.Raw(`SELECT a.lot_id::text FROM wallet_lot_allocations a JOIN game_sessions s ON s.spend_transaction_id = a.transaction_id WHERE s.id = ?`, deactivated).Scan(&spendLot).Error) + require.NoError(t, e.db.Raw(`SELECT spend_transaction_id::text FROM game_sessions WHERE id = ?`, deactivated).Scan(&spendTx).Error) + assert.Equal(t, spendLot, lot.OriginLotID) + assert.Equal(t, spendTx, lot.Reverses) + assert.True(t, !lot.ExpiresAt.Before(e.now.Add(7*24*time.Hour-time.Minute)), "expires %s", lot.ExpiresAt) + assert.Equal(t, int64(2), e.count(`SELECT COUNT(*) FROM audit_logs WHERE entity_type = 'GAME_SESSION' AND action = 'REFUNDED' AND actor_type = 'SYSTEM' AND source = 'session_job' AND organization_id = ?`, e.orgA)) + + refundedAgain, err := e.sessions.RefundSession(ctx, e.orgA, deactivated, constants.GameSessionRefundSystemError, constants.AuditSourceSessionJob) + require.NoError(t, err) + assert.False(t, refundedAgain, "a session is refunded once") + + // Completing and refunding the same session at once: exactly one wins. + sessionsRepo := repository.NewGameSessionRepository(e.db) + for round := 0; round < 5; round++ { + id := start(e.bob, other.ID, "race-"+string(rune('a'+round))) + var wg sync.WaitGroup + var completed, refundedNow bool + begin := make(chan struct{}) + wg.Add(2) + go func() { + defer wg.Done() + <-begin + assert.NoError(t, e.txm.WithTransaction(ctx, func(ctx context.Context) error { + if err := e.wallet.LockWallet(ctx, e.bob); err != nil { + return err + } + var err error + completed, err = sessionsRepo.CompleteSession(ctx, id, repository.GameSessionCompletion{EndedAt: e.now}) + return err + })) + }() + go func() { + defer wg.Done() + <-begin + var err error + refundedNow, err = e.sessions.RefundSession(ctx, e.orgA, id, constants.GameSessionRefundSystemError, constants.AuditSourceSessionJob) + assert.NoError(t, err) + }() + close(begin) + wg.Wait() + assert.True(t, completed != refundedNow, "round %d: completed %v, refunded %v", round, completed, refundedNow) + refunds := e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'GAME_SPEND_REFUND' AND reference_id = ?`, id) + assert.Equal(t, map[bool]int64{true: 1, false: 0}[refundedNow], refunds, "round %d", round) + } +} + +// EG-206 +func TestGameSession_CustomerReads(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + game := e.playableGame(e.orgA, "runner", 2) + _, err := e.admin.CreateGame(ctx, e.orgA, e.manager, models.EnakGameInput{Name: "Draft", Slug: "draft", EntryCost: 1}) + require.NoError(t, err) + e.playableGame(e.orgB, "theirs", 1) + e.coins(e.alice, 10, nil) + + games, err := e.sessions.ListGames(ctx, e.alice) + require.NoError(t, err) + require.Len(t, games, 1, "only the ACTIVE games of the customer's organization") + assert.Equal(t, game.ID, games[0].ID) + assert.Equal(t, "runner", games[0].Slug) + assert.EqualValues(t, 2, games[0].EntryCost) + + started, err := e.sessions.Start(ctx, e.alice, game.ID, "k") + require.NoError(t, err) + require.NoError(t, e.db.Exec(`UPDATE game_sessions SET reward_breakdown = '{"roll": 7}', flagged = TRUE WHERE id = ?`, started.SessionID).Error) + session, err := e.sessions.GetSession(ctx, e.alice, started.SessionID) + require.NoError(t, err) + assert.Equal(t, constants.GameSessionStatusStarted, session.Status) + raw, err := json.Marshal(session) + require.NoError(t, err) + for _, internal := range []string{"reward_breakdown", "roll", "flagged", "result"} { + assert.NotContains(t, string(raw), internal) + } + _, err = e.sessions.GetSession(ctx, e.bob, started.SessionID) + assert.ErrorIs(t, err, repository.ErrGameSessionNotFound, "another customer's session") + + page, err := e.sessions.ListSessions(ctx, e.alice, 1, 10) + require.NoError(t, err) + assert.EqualValues(t, 1, page.Pagination.Total) + page, err = e.sessions.ListSessions(ctx, e.bob, 1, 10) + require.NoError(t, err) + assert.Empty(t, page.Data) +} + +// gameWith makes an ACTIVE game of org A with the given result rules and an active +// config of the given type, rules and cap. +func (e *enakGameEnv) gameWith(slug string, resultRules entities.GameResultRules, rewardType, rules string, maxReward int64) *models.EnakGame { + e.t.Helper() + game, err := e.admin.CreateGame(e.ctx(), e.orgA, e.manager, models.EnakGameInput{ + Name: "Game " + slug, Slug: slug, Status: constants.GameStatusActive, EntryCost: 2, ResultRules: resultRules, + }) + require.NoError(e.t, err) + config, err := e.admin.CreateRewardConfig(e.ctx(), e.orgA, e.manager, game.ID, models.GameRewardConfigInput{ + RewardType: rewardType, Rules: json.RawMessage(rules), MaxReward: maxReward, + }) + require.NoError(e.t, err) + _, err = e.admin.ActivateRewardConfig(e.ctx(), e.orgA, e.manager, config.ID, models.GameRewardConfigActivateInput{}) + require.NoError(e.t, err) + if e.count(`SELECT COUNT(*) FROM game_budgets WHERE organization_id = ? AND scope = 'GLOBAL'`, e.orgA) == 0 { + e.globalBudget(e.orgA, e.now) + } + return game +} + +func (e *enakGameEnv) session(id uuid.UUID) entities.GameSession { + e.t.Helper() + var s entities.GameSession + require.NoError(e.t, e.db.Where("id = ?", id).First(&s).Error) + return s +} + +// failingRewards fails right after the reward credit, to show the whole completion +// rolls back. +type failingRewards struct { + repository.GameSessionRepository +} + +func (failingRewards) CreateSessionRewards(context.Context, []entities.GameSessionReward) error { + return assert.AnError +} + +// EG-303 +func TestGameSession_Complete(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + score := func(v int64) *int64 { return &v } + maxScore, minDuration := int64(1000), int64(5) + scored := e.gameWith("scored", entities.GameResultRules{MaxScore: &maxScore, MinDurationSeconds: &minDuration}, + constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}`, 15) + e.coins(e.alice, 100, nil) + e.coins(e.bob, 100, nil) + start := func(customer, gameID uuid.UUID, key string) uuid.UUID { + t.Helper() + s, err := e.sessions.Start(ctx, customer, gameID, key) + require.NoError(t, err) + return s.SessionID + } + later := func(d time.Duration) { e.now = e.now.Add(d) } + + t.Run("pays the base reward once, capped by max_reward", func(t *testing.T) { + id := start(e.alice, scored.ID, "a1") + later(30 * time.Second) + first, err := e.sessions.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{Score: score(500)}) + require.NoError(t, err) + assert.Equal(t, constants.GameSessionStatusCompleted, first.Status) + assert.EqualValues(t, 15, first.RewardTotal, "20 capped at 15") + assert.EqualValues(t, 15, first.Reward.Base) + assert.EqualValues(t, 100-2+15, first.CoinBalance) + + second, err := e.sessions.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{Score: score(999)}) + require.NoError(t, err) + assert.Equal(t, first, second, "completing again answers the same") + assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'GAME_REWARD' AND reference_id = ?`, id)) + + var reward struct { + Amount int64 + IdempotencyKey string + BudgetID string + } + require.NoError(t, e.db.Raw(`SELECT t.amount, t.idempotency_key, r.budget_id::text AS budget_id + FROM game_session_rewards r JOIN wallet_transactions t ON t.id = r.wallet_transaction_id WHERE r.session_id = ?`, id).Scan(&reward).Error) + var global string + require.NoError(t, e.db.Raw(`SELECT id::text FROM game_budgets WHERE organization_id = ? AND scope = 'GLOBAL'`, e.orgA).Scan(&global).Error) + assert.EqualValues(t, 15, reward.Amount) + assert.Equal(t, "game-reward:"+id.String()+":"+global, reward.IdempotencyKey) + assert.Equal(t, global, reward.BudgetID) + + s := e.session(id) + assert.False(t, s.Flagged) + var breakdown map[string]any + require.NoError(t, json.Unmarshal(s.RewardBreakdown, &breakdown)) + assert.EqualValues(t, 20, breakdown["base"]) + assert.Equal(t, true, breakdown["capped"]) + assert.EqualValues(t, 1, breakdown["config_version"]) + assert.JSONEq(t, `{"score": 500}`, string(s.Result)) + }) + + t.Run("an unbelievable result earns nothing and is flagged", func(t *testing.T) { + for name, tc := range map[string]struct { + after time.Duration + in models.GameSessionCompleteInput + }{ + "too fast": {time.Second, models.GameSessionCompleteInput{Score: score(10)}}, + "score above max": {time.Minute, models.GameSessionCompleteInput{Score: score(5000)}}, + "no score": {time.Minute, models.GameSessionCompleteInput{}}, + } { + before := e.coinBalance(e.bob) + id := start(e.bob, scored.ID, "flag-"+name) + later(tc.after) + out, err := e.sessions.Complete(ctx, e.bob, id, tc.in) + require.NoError(t, err, name) + assert.Equal(t, constants.GameSessionStatusCompleted, out.Status, name) + assert.Zero(t, out.RewardTotal, name) + s := e.session(id) + assert.True(t, s.Flagged, name) + assert.Nil(t, s.RefundTransactionID, name) + assert.Contains(t, string(s.RewardBreakdown), "rejections", name) + assert.EqualValues(t, before-2, e.coinBalance(e.bob), "%s: the entry cost stays spent", name) + } + }) + + t.Run("someone else's session, an expired one", func(t *testing.T) { + id := start(e.alice, scored.ID, "a2") + _, err := e.sessions.Complete(ctx, e.bob, id, models.GameSessionCompleteInput{Score: score(1)}) + assert.ErrorIs(t, err, repository.ErrGameSessionNotFound) + later(11 * time.Minute) + _, err = e.sessions.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{Score: score(1)}) + assert.ErrorIs(t, err, ErrGameSessionRejected) + assert.Equal(t, constants.GameSessionStatusStarted, e.session(id).Status, "left for the session job") + assert.Nil(t, e.session(id).CompletionFailedAt, "a refusal is not a system error") + }) + + t.Run("a game turned off during the play refunds the entry cost", func(t *testing.T) { + off := e.gameWith("turned-off", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 5}`, 0) + before := e.coinBalance(e.alice) + id := start(e.alice, off.ID, "a3") + _, err := e.admin.SetGameStatus(ctx, e.orgA, e.manager, off.ID, models.EnakGameStatusInput{Status: "INACTIVE"}) + require.NoError(t, err) + out, err := e.sessions.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{}) + require.NoError(t, err) + assert.Equal(t, constants.GameSessionStatusRefunded, out.Status) + assert.Equal(t, constants.GameSessionRefundGameDeactivated, *out.RefundReason) + assert.EqualValues(t, before, out.CoinBalance) + assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM audit_logs WHERE entity_id = ? AND source = 'customer_api'`, id)) + again, err := e.sessions.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{}) + require.NoError(t, err) + assert.Equal(t, out, again) + }) + + t.Run("a failure after the credit rolls everything back and marks the session", func(t *testing.T) { + broken := *e.sessions + broken.sessions = failingRewards{repository.NewGameSessionRepository(e.db)} + before := e.coinBalance(e.alice) + id := start(e.alice, scored.ID, "a4") + later(time.Minute) + _, err := broken.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{Score: score(50)}) + require.ErrorIs(t, err, assert.AnError) + s := e.session(id) + assert.Equal(t, constants.GameSessionStatusStarted, s.Status) + assert.Zero(t, s.RewardTotal) + assert.NotNil(t, s.CompletionFailedAt) + assert.Zero(t, e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'GAME_REWARD' AND reference_id = ?`, id)) + assert.EqualValues(t, before-2, e.coinBalance(e.alice)) + + // Once it expires, the session job refunds it as a system error. + later(11 * time.Minute) + refunded, _, err := e.sessions.ProcessDueSessions(ctx) + require.NoError(t, err) + assert.GreaterOrEqual(t, refunded, 1) + assert.Equal(t, constants.GameSessionRefundSystemError, *e.session(id).RefundReason) + assert.EqualValues(t, before, e.coinBalance(e.alice)) + }) + + t.Run("PROBABILITY draws on complete and keeps the draw", func(t *testing.T) { + lucky := e.gameWith("lucky", entities.GameResultRules{}, constants.GameRewardTypeProbability, + `{"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}`, 0) + id := start(e.bob, lucky.ID, "b-lucky") + e.sessions.rng = &fixedRNG{draws: []int64{0}} + defer func() { e.sessions.rng = CryptoRewardRNG{} }() + out, err := e.sessions.Complete(ctx, e.bob, id, models.GameSessionCompleteInput{}) + require.NoError(t, err) + assert.EqualValues(t, 1000, out.RewardTotal) + var breakdown map[string]any + require.NoError(t, json.Unmarshal(e.session(id).RewardBreakdown, &breakdown)) + assert.EqualValues(t, 0, breakdown["detail"].(map[string]any)["roll"]) + }) + + t.Run("an outcome worth nothing completes without a ledger row", func(t *testing.T) { + outcomes := e.gameWith("outcomes", entities.GameResultRules{}, constants.GameRewardTypeOutcomeBased, `{"outcomes": {"WIN": 5, "FAIL": 0}}`, 0) + id := start(e.bob, outcomes.ID, "b-fail") + fail := "FAIL" + out, err := e.sessions.Complete(ctx, e.bob, id, models.GameSessionCompleteInput{Outcome: &fail}) + require.NoError(t, err) + assert.Zero(t, out.RewardTotal) + assert.False(t, e.session(id).Flagged) + assert.Zero(t, e.count(`SELECT COUNT(*) FROM game_session_rewards WHERE session_id = ?`, id)) + }) +} + +// EG-402 +func TestGameSession_EconomyGuard(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), e.txm) + t.Cleanup(func() { + e.db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, e.orgA) + e.db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, e.orgA) + }) + setLimits := func(user, global int64) { + t.Helper() + s, err := settings.Organization(ctx, e.orgA) + require.NoError(t, err) + s.EnakGame = models.EnakGameLimitSettings{UserDailyLimit: user, GlobalDailyLimit: global} + _, _, err = settings.UpdateOrganization(ctx, e.orgA, e.manager, *s) + require.NoError(t, err) + } + counter := func(scope string, id uuid.UUID) int64 { + t.Helper() + got, err := repository.NewGameRewardCounterRepository(e.db).Get(ctx, e.orgA, scope, id, walletDay(e.now)) + require.NoError(t, err) + return got + } + gameDaily := int64(1000) + thirty := e.gameWith("thirty", entities.GameResultRules{DailyRewardLimit: &gameDaily}, constants.GameRewardTypeFixed, `{"amount": 30}`, 0) + e.coins(e.alice, 100, nil) + e.coins(e.bob, 100, nil) + play := func(customer uuid.UUID, gameID uuid.UUID, key string) (*models.GameSessionCompletion, uuid.UUID) { + t.Helper() + s, err := e.sessions.Start(ctx, customer, gameID, key) + require.NoError(t, err) + out, err := e.sessions.Complete(ctx, customer, s.SessionID, models.GameSessionCompleteInput{}) + require.NoError(t, err) + return out, s.SessionID + } + + // No limits: every reward is counted all the same. + out, _ := play(e.alice, thirty.ID, "a0") + assert.EqualValues(t, 30, out.RewardTotal) + assert.Empty(t, out.LimitedBy) + assert.EqualValues(t, 30, counter(constants.GameRewardScopeUser, e.alice)) + assert.EqualValues(t, 30, counter(constants.GameRewardScopeGlobal, e.orgA)) + + // Alice has 10 left of her 40: a reward of 30 pays 10 and says why. + setLimits(40, 0) + out, id := play(e.alice, thirty.ID, "a1") + assert.EqualValues(t, 10, out.RewardTotal) + assert.Equal(t, []string{"USER_DAILY"}, out.LimitedBy) + var breakdown gameRewardBreakdown + require.NoError(t, json.Unmarshal(e.session(id).RewardBreakdown, &breakdown)) + assert.Equal(t, []gameGuardCut{{Limit: "USER_DAILY", Value: 40, Asked: 30, Allowed: 10}}, breakdown.Guard) + assert.EqualValues(t, 30, breakdown.Base, "the base stays what was earned") + assert.EqualValues(t, 10, breakdown.Total) + assert.Equal(t, int64(10), e.count(`SELECT amount FROM wallet_transactions WHERE type = 'GAME_REWARD' AND reference_id = ?`, id)) + again, err := e.sessions.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{}) + require.NoError(t, err) + assert.Equal(t, out, again, "the limit is part of the repeated answer") + + // Nothing left: the session completes with no reward and no ledger row. + out, id = play(e.alice, thirty.ID, "a2") + assert.Zero(t, out.RewardTotal) + assert.Equal(t, []string{"USER_DAILY"}, out.LimitedBy) + assert.Equal(t, constants.GameSessionStatusCompleted, e.session(id).Status) + assert.False(t, e.session(id).Flagged) + assert.Zero(t, e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'GAME_REWARD' AND reference_id = ?`, id)) + assert.EqualValues(t, 40, counter(constants.GameRewardScopeUser, e.alice)) + + // The organization's limit cuts Bob, and the counters before it give the + // difference back: every counter holds what was paid. + setLimits(0, 75) + out, _ = play(e.bob, thirty.ID, "b1") + assert.EqualValues(t, 30, out.RewardTotal) + out, _ = play(e.bob, thirty.ID, "b2") + assert.EqualValues(t, 5, out.RewardTotal, "70 of 75 already given today") + assert.Equal(t, []string{"GLOBAL_DAILY"}, out.LimitedBy) + assert.EqualValues(t, 35, counter(constants.GameRewardScopeUser, e.bob)) + assert.EqualValues(t, 75, counter(constants.GameRewardScopeGame, thirty.ID)) + assert.EqualValues(t, 75, counter(constants.GameRewardScopeGlobal, e.orgA)) + + // The game's own daily limit, and a new day starting over. + setLimits(0, 0) + small := int64(12) + capped := e.gameWith("capped", entities.GameResultRules{DailyRewardLimit: &small}, constants.GameRewardTypeFixed, `{"amount": 30}`, 0) + out, _ = play(e.bob, capped.ID, "b3") + assert.EqualValues(t, 12, out.RewardTotal) + assert.Equal(t, []string{"GAME_DAILY"}, out.LimitedBy) + e.now = e.now.Add(24 * time.Hour) + if e.count(`SELECT COUNT(*) FROM game_budgets WHERE organization_id = ? AND period_start <= ?::date AND period_end >= ?::date`, + e.orgA, walletDay(e.now).Format("2006-01-02"), walletDay(e.now).Format("2006-01-02")) == 0 { + e.globalBudget(e.orgA, e.now) + } + out, _ = play(e.bob, capped.ID, "b4") + assert.EqualValues(t, 12, out.RewardTotal, "the next day has its own count") +} diff --git a/internal/processor/game_budget_metrics_db_test.go b/internal/processor/game_budget_metrics_db_test.go new file mode 100644 index 0000000..67433aa --- /dev/null +++ b/internal/processor/game_budget_metrics_db_test.go @@ -0,0 +1,95 @@ +package processor + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// EG-601 against Postgres, with fixed data giving the PRD §30 numbers. Needs +// TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go. +func TestGameBudgetMetrics_AgainstPostgres(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + e.now = time.Date(2026, 10, 21, 12, 0, 0, 0, walletDisplayLocation) + metrics := NewGameBudgetMetricsProcessor(repository.NewGameBudgetRepository(e.db), repository.NewGameBudgetMetricsRepository(e.db)) + metrics.now = func() time.Time { return e.now } + + october := e.globalBudget(e.orgA, e.now) + event, err := e.budgets.CreateBudget(ctx, e.orgA, e.manager, models.GameBudgetInput{ + Scope: "EVENT", Name: "Ramadan", PeriodStart: "2026-10-10", PeriodEnd: "2026-10-20", Amount: 10_000_000, + Thresholds: models.GameBudgetThresholds{Warning: ptr(int64(20)), Critical: ptr(int64(50))}, + }) + require.NoError(t, err) + + voucher := e.staticVoucher(1_000, 1, 1) + // cost records a completed redemption whose face value went to budget (nil: EnakPoint + // not from EnakGame), recognized at a time in Jakarta. + cost := func(budget *uuid.UUID, source string, amount int64, day, hour int) { + t.Helper() + at := time.Date(2026, time.Month(day/100), day%100, hour, 0, 0, 0, walletDisplayLocation) + debit, redemption := uuid.New(), uuid.New() + require.NoError(t, e.db.Exec(`INSERT INTO wallet_transactions (id, organization_id, customer_id, currency, type, amount, balance_after, reference_type, reference_id, description) + VALUES (?, ?, ?, 'POINT', 'REWARD_REDEEM', -1, 0, 'REWARD_REDEMPTION', ?, 'x')`, debit, e.orgA, e.bob, redemption).Error) + require.NoError(t, e.db.Exec(`INSERT INTO voucher_redemptions (id, organization_id, customer_id, voucher_id, idempotency_key, status, face_value, point_cost, debit_transaction_id, completed_at) + VALUES (?, ?, ?, ?, ?, 'COMPLETED', ?, 1, ?, ?)`, redemption, e.orgA, e.bob, voucher.ID, redemption.String(), amount, debit, at).Error) + require.NoError(t, e.db.Exec(`INSERT INTO voucher_redemption_costs (redemption_id, budget_id, source_type, points, cost, recognized_at) VALUES (?, ?, ?, 1, ?, ?)`, + redemption, budget, source, amount, at).Error) + } + global, ev := october.ID, event.ID + cost(&global, "GAME_REWARD", 5_000_000, 930, 23) // 30 Sep 23:00 WIB: the month before + cost(&global, "GAME_REWARD", 21_500_000, 1005, 1) // before the forecast window + for day := 1015; day <= 1021; day++ { // the last seven days, today included + cost(&global, "GAME_REWARD", 5_500_000, day, 0) + } + cost(nil, "EARN", 9_000_000, 1020, 10) // shopping EnakPoint: no budget + cost(&ev, "GAME_REWARD", 1_000_000, 1012, 10) + cost(&ev, "GAME_REWARD", 2_000_000, 1021, 9) // after the event ended: still its cost + + // EnakCoin the global budget paid for, partly exchanged into EnakPoint. + paid := e.rewardCoins(e.alice, 100) + require.Equal(t, october.ID, paid) + e.exchangeCoins(e.alice, 40) + + m, err := metrics.Metrics(ctx, e.orgA, october.ID) + require.NoError(t, err) + assert.Equal(t, "2026-10-21", m.AsOf) + assert.EqualValues(t, 100_000_000, m.Amount) + assert.EqualValues(t, 60_000_000, m.RealizedCost, "within October, from EnakGame only") + assert.EqualValues(t, 40_000_000, m.Remaining) + assert.EqualValues(t, 60, m.UtilizationPercent) + assert.EqualValues(t, 7, m.WindowDays) + assert.EqualValues(t, 10, m.RemainingDays) + assert.EqualValues(t, 5_500_000, m.DailyBurn) + assert.EqualValues(t, 115_000_000, m.ForecastCost) + assert.EqualValues(t, -15_000_000, m.ForecastRemaining) + assert.Equal(t, constants.GameBudgetCritical, m.Status) + assert.EqualValues(t, 100, m.CoinIssued) + assert.Equal(t, models.GameBudgetExposure{Coins: 60, Points: 40}, m.Exposure, "followed through the exchange") + + m, err = metrics.Metrics(ctx, e.orgA, event.ID) + require.NoError(t, err) + assert.EqualValues(t, 3_000_000, m.RealizedCost, "an event budget has no time bound") + assert.Zero(t, m.RemainingDays, "the event is over") + assert.EqualValues(t, 3_000_000, m.ForecastCost) + assert.EqualValues(t, 30, m.UtilizationPercent) + assert.Equal(t, constants.GameBudgetWarning, m.Status, "its own thresholds: 20 and 50") + assert.Zero(t, m.CoinIssued) + + _, err = metrics.Metrics(ctx, e.orgB, october.ID) + assert.ErrorIs(t, err, repository.ErrGameBudgetNotFound) + + // Redeeming the EnakPoint lowers the exposure; spending EnakCoin on a game does too. + _, err = e.redeemVoucher(e.alice, voucher.ID, "metrics") + require.NoError(t, err) + m, err = metrics.Metrics(ctx, e.orgA, october.ID) + require.NoError(t, err) + assert.Equal(t, models.GameBudgetExposure{Coins: 60, Points: 39}, m.Exposure) +} diff --git a/internal/processor/game_budget_metrics_processor.go b/internal/processor/game_budget_metrics_processor.go new file mode 100644 index 0000000..3e5bc1d --- /dev/null +++ b/internal/processor/game_budget_metrics_processor.go @@ -0,0 +1,155 @@ +package processor + +import ( + "context" + "encoding/json" + "math" + "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" +) + +// budgetForecastWindowDays is how many days of realized cost the forecast averages +// (docs/rfc-enakgame.md §10). +const budgetForecastWindowDays = 7 + +// GameBudgetMetricsProcessor computes how a budget stands (§10, PRD §8, §32), on read. +// +// Realized cost is what redeemed vouchers cost the budget: for a global budget, what +// was recognized within its period; for an event budget, whenever it was recognized, +// because the cost was born from that event's rewards. The forecast adds the average +// daily cost of the last seven days, today included, for each day left. +type GameBudgetMetricsProcessor struct { + budgets repository.GameBudgetRepository + metrics repository.GameBudgetMetricsRepository + now func() time.Time +} + +func NewGameBudgetMetricsProcessor(budgets repository.GameBudgetRepository, metrics repository.GameBudgetMetricsRepository) *GameBudgetMetricsProcessor { + return &GameBudgetMetricsProcessor{budgets: budgets, metrics: metrics, now: time.Now} +} + +// budgetMetricInputs is what the metrics are computed from. +type budgetMetricInputs struct { + Realized int64 + RealizedWindow int64 + WindowDays int64 + RemainingDays int64 + CoinIssued int64 + Coins, Points int64 +} + +func (p *GameBudgetMetricsProcessor) Metrics(ctx context.Context, organizationID, budgetID uuid.UUID) (*models.GameBudgetMetrics, error) { + budget, err := p.budgets.GetBudget(ctx, organizationID, budgetID) + if err != nil { + return nil, err + } + now := p.now() + today := civilDay(walletDay(now)) + start, end := civilDay(budget.PeriodStart), civilDay(budget.PeriodEnd) + + var in budgetMetricInputs + var from, to *time.Time + if budget.Scope == constants.GameBudgetScopeGlobal { + f, t := jakartaMidnight(start), jakartaMidnight(end.AddDate(0, 0, 1)) + from, to = &f, &t + } + if in.Realized, err = p.metrics.RealizedCost(ctx, budget.ID, from, to); err != nil { + return nil, err + } + if !today.Before(start) { + windowStart := today.AddDate(0, 0, 1-budgetForecastWindowDays) + if windowStart.Before(start) { + windowStart = start + } + in.WindowDays = daysBetween(windowStart, today) + 1 + w := jakartaMidnight(windowStart) + if in.RealizedWindow, err = p.metrics.RealizedCost(ctx, budget.ID, &w, to); err != nil { + return nil, err + } + if in.RemainingDays = daysBetween(today, end); in.RemainingDays < 0 { + in.RemainingDays = 0 + } + } + if in.CoinIssued, err = p.metrics.CoinIssued(ctx, budget.ID); err != nil { + return nil, err + } + if in.Coins, in.Points, err = p.metrics.Exposure(ctx, budget.ID, now); err != nil { + return nil, err + } + m := budgetMetrics(budget, today, in) + return &m, nil +} + +// budgetMetrics computes the metrics of a budget on a day from what was read. +func budgetMetrics(b *entities.GameBudget, today time.Time, in budgetMetricInputs) models.GameBudgetMetrics { + m := models.GameBudgetMetrics{ + BudgetID: b.ID, Scope: b.Scope, Amount: b.Amount, + PeriodStart: b.PeriodStart.Format("2006-01-02"), PeriodEnd: b.PeriodEnd.Format("2006-01-02"), AsOf: today.Format("2006-01-02"), + RealizedCost: in.Realized, Remaining: b.Amount - in.Realized, + WindowDays: in.WindowDays, RemainingDays: in.RemainingDays, ForecastCost: in.Realized, + CoinIssued: in.CoinIssued, Exposure: models.GameBudgetExposure{Coins: in.Coins, Points: in.Points}, + } + if in.WindowDays > 0 { + m.DailyBurn = in.RealizedWindow / in.WindowDays + // Computed from the window's total, not the rounded daily average, so whole + // rupiah are not lost day after day. + m.ForecastCost += in.RealizedWindow * in.RemainingDays / in.WindowDays + } + m.ForecastRemaining = b.Amount - m.ForecastCost + m.UtilizationPercent = percentOf(m.RealizedCost, b.Amount) + m.ForecastUtilizationPercent = percentOf(m.ForecastCost, b.Amount) + + warning, critical := constants.GameBudgetWarningDefault, constants.GameBudgetCriticalDefault + var set models.GameBudgetThresholds + if len(b.Thresholds) > 0 { + _ = json.Unmarshal(b.Thresholds, &set) + } + if set.Warning != nil { + warning = *set.Warning + } + if set.Critical != nil { + critical = *set.Critical + } + m.Thresholds = models.GameBudgetThresholds{Warning: &warning, Critical: &critical} + + highest := math.Max(m.UtilizationPercent, m.ForecastUtilizationPercent) + switch { + case m.RealizedCost >= b.Amount: + m.Status = constants.GameBudgetExhausted + case m.ForecastCost > b.Amount, highest >= float64(critical): + m.Status = constants.GameBudgetCritical + case highest >= float64(warning): + m.Status = constants.GameBudgetWarning + default: + m.Status = constants.GameBudgetHealthy + } + return m +} + +// percentOf is part as a percent of whole, with two decimals. +func percentOf(part, whole int64) float64 { + if whole <= 0 { + return 0 + } + return math.Round(float64(part)*10000/float64(whole)) / 100 +} + +// civilDay is the calendar date of t, as midnight UTC, for counting days. +func civilDay(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) +} + +// jakartaMidnight is when a calendar date starts in Asia/Jakarta. +func jakartaMidnight(day time.Time) time.Time { + return time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, walletDisplayLocation) +} + +func daysBetween(a, b time.Time) int64 { + return int64(math.Round(b.Sub(a).Hours() / 24)) +} diff --git a/internal/processor/game_budget_metrics_processor_test.go b/internal/processor/game_budget_metrics_processor_test.go new file mode 100644 index 0000000..bbb027c --- /dev/null +++ b/internal/processor/game_budget_metrics_processor_test.go @@ -0,0 +1,61 @@ +package processor + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" +) + +func TestBudgetMetrics(t *testing.T) { + october := &entities.GameBudget{ + Scope: constants.GameBudgetScopeGlobal, Amount: 100_000_000, + PeriodStart: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), PeriodEnd: time.Date(2026, 10, 31, 0, 0, 0, 0, time.UTC), + } + today := time.Date(2026, 10, 21, 0, 0, 0, 0, time.UTC) + + // PRD §30: Rp100M, Rp60M realized, 10 days left, burning Rp5,5M a day → Rp115M. + m := budgetMetrics(october, today, budgetMetricInputs{Realized: 60_000_000, RealizedWindow: 38_500_000, WindowDays: 7, RemainingDays: 10}) + assert.EqualValues(t, 40_000_000, m.Remaining) + assert.EqualValues(t, 60, m.UtilizationPercent) + assert.EqualValues(t, 5_500_000, m.DailyBurn) + assert.EqualValues(t, 115_000_000, m.ForecastCost) + assert.EqualValues(t, -15_000_000, m.ForecastRemaining) + assert.EqualValues(t, 115, m.ForecastUtilizationPercent) + assert.Equal(t, constants.GameBudgetCritical, m.Status, "the forecast passes the budget") + assert.EqualValues(t, 70, *m.Thresholds.Warning, "the default when the budget sets none") + assert.EqualValues(t, 90, *m.Thresholds.Critical) + assert.Equal(t, "2026-10-21", m.AsOf) + + for name, tc := range map[string]struct { + thresholds string + realized, burn int64 + want string + }{ + "quiet": {"", 10_000_000, 0, constants.GameBudgetHealthy}, + "forecast at warning": {"", 30_000_000, 4_000_000, constants.GameBudgetWarning}, // 30 + 4 × 10 = 70% + "realized at critical": {"", 90_000_000, 0, constants.GameBudgetCritical}, // 90% + "spent": {"", 100_000_000, 0, constants.GameBudgetExhausted}, // 100% + "over": {"", 120_000_000, 0, constants.GameBudgetExhausted}, // 120% + "own thresholds": {`{"warning": 20, "critical": 50}`, 25_000_000, 0, constants.GameBudgetWarning}, + "own critical only": {`{"critical": 30}`, 31_000_000, 0, constants.GameBudgetCritical}, + "own warning above default": {`{"warning": 80}`, 75_000_000, 0, constants.GameBudgetHealthy}, + } { + b := *october + b.Thresholds = entities.JSONDocument(tc.thresholds) + m := budgetMetrics(&b, today, budgetMetricInputs{Realized: tc.realized, RealizedWindow: tc.burn * 7, WindowDays: 7, RemainingDays: 10}) + assert.Equal(t, tc.want, m.Status, name) + } + + // Not started yet: nothing to average, the forecast is what is realized. + m = budgetMetrics(october, time.Date(2026, 9, 20, 0, 0, 0, 0, time.UTC), budgetMetricInputs{}) + assert.Zero(t, m.ForecastCost) + assert.Equal(t, constants.GameBudgetHealthy, m.Status) + // A third of a rupiah per day is not lost: 10 over 3 days, 3 days left → 10 more. + m = budgetMetrics(october, today, budgetMetricInputs{Realized: 10, RealizedWindow: 10, WindowDays: 3, RemainingDays: 3}) + assert.EqualValues(t, 20, m.ForecastCost) + assert.EqualValues(t, 3, m.DailyBurn) +} diff --git a/internal/processor/game_budget_processor.go b/internal/processor/game_budget_processor.go new file mode 100644 index 0000000..a0d616a --- /dev/null +++ b/internal/processor/game_budget_processor.go @@ -0,0 +1,257 @@ +package processor + +import ( + "context" + "encoding/json" + "errors" + "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" +) + +// GameBudgetProcessor manages what an organization may spend on EnakGame rewards +// (docs/rfc-enakgame.md §5.6, §12). Global budgets of an organization never share a +// day; every change is audited in its transaction (§13). +type GameBudgetProcessor struct { + budgets repository.GameBudgetRepository + audit *AuditLogger + tx TxRunner + now func() time.Time +} + +func NewGameBudgetProcessor(budgets repository.GameBudgetRepository, audit *AuditLogger, tx TxRunner) *GameBudgetProcessor { + return &GameBudgetProcessor{budgets: budgets, audit: audit, tx: tx, now: time.Now} +} + +// GameBudgetInputFrom is a budget's current values as an input, for a change that only +// sends the fields it changes. +func GameBudgetInputFrom(b *models.GameBudget) models.GameBudgetInput { + return models.GameBudgetInput{ + Scope: b.Scope, Name: b.Name, PeriodStart: b.PeriodStart, PeriodEnd: b.PeriodEnd, + Amount: b.Amount, Thresholds: b.Thresholds, + } +} + +func (p *GameBudgetProcessor) record(ctx context.Context, organizationID, actor, id uuid.UUID, action string, before, after any) error { + return p.audit.Record(ctx, AuditEntry{ + OrganizationID: organizationID, ActorType: constants.AuditActorUser, ActorID: &actor, + EntityType: constants.AuditEntityGameBudget, EntityID: id, Action: action, + Before: before, After: after, Source: constants.AuditSourceAdminAPI, + }) +} + +func (p *GameBudgetProcessor) CreateBudget(ctx context.Context, organizationID, actor uuid.UUID, in models.GameBudgetInput) (*models.GameBudget, error) { + budget, err := gameBudgetFromInput(in) + if err != nil { + return nil, err + } + budget.OrganizationID, budget.CreatedBy = organizationID, actor + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if err := p.checkGlobalPeriod(ctx, budget, nil); err != nil { + return err + } + if err := p.budgets.CreateBudget(ctx, budget); err != nil { + return err + } + return p.record(ctx, organizationID, actor, budget.ID, "CREATED", nil, gameBudgetModel(budget)) + }) + if err != nil { + return nil, gameBudgetError(err) + } + return gameBudgetModel(budget), nil +} + +func (p *GameBudgetProcessor) GetBudget(ctx context.Context, organizationID, id uuid.UUID) (*models.GameBudget, error) { + budget, err := p.budgets.GetBudget(ctx, organizationID, id) + if err != nil { + return nil, err + } + return gameBudgetModel(budget), nil +} + +func (p *GameBudgetProcessor) ListBudgets(ctx context.Context, organizationID uuid.UUID, q models.GameBudgetListQuery) (*models.PaginatedResponse[models.GameBudget], error) { + page, limit := enakGamePage(q.Page, q.Limit) + scope := strings.ToUpper(strings.TrimSpace(q.Scope)) + if scope != "" && scope != constants.GameBudgetScopeGlobal && scope != constants.GameBudgetScopeEvent { + return nil, enakGameRejected("scope must be GLOBAL or EVENT") + } + budgets, total, err := p.budgets.ListBudgets(ctx, repository.GameBudgetFilter{ + OrganizationID: organizationID, Scope: scope, Offset: (page - 1) * limit, Limit: limit, + }) + if err != nil { + return nil, err + } + items := make([]models.GameBudget, 0, len(budgets)) + for i := range budgets { + items = append(items, *gameBudgetModel(&budgets[i])) + } + return &models.PaginatedResponse[models.GameBudget]{Data: items, Pagination: enakGamePagination(page, limit, total)}, nil +} + +// UpdateBudget changes a budget's name, period, amount and thresholds. Its scope stays. +func (p *GameBudgetProcessor) UpdateBudget(ctx context.Context, organizationID, actor, id uuid.UUID, in models.GameBudgetInput) (*models.GameBudget, error) { + next, err := gameBudgetFromInput(in) + if err != nil { + return nil, err + } + var after *models.GameBudget + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + current, err := p.budgets.GetBudget(ctx, organizationID, id) + if err != nil { + return err + } + if next.Scope != current.Scope { + return enakGameRejected("the scope of a budget cannot change") + } + next.ID, next.OrganizationID, next.CreatedBy, next.CreatedAt = current.ID, current.OrganizationID, current.CreatedBy, current.CreatedAt + if err := p.checkGlobalPeriod(ctx, next, &id); err != nil { + return err + } + if err := p.budgets.UpdateBudget(ctx, next); err != nil { + return err + } + saved, err := p.budgets.GetBudget(ctx, organizationID, id) + if err != nil { + return err + } + after = gameBudgetModel(saved) + return p.record(ctx, organizationID, actor, id, "UPDATED", gameBudgetModel(current), after) + }) + if err != nil { + return nil, gameBudgetError(err) + } + return after, nil +} + +// DeleteBudget removes a budget that no reward or event points at. +func (p *GameBudgetProcessor) DeleteBudget(ctx context.Context, organizationID, actor, id uuid.UUID) error { + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + current, err := p.budgets.GetBudget(ctx, organizationID, id) + if err != nil { + return err + } + if err := p.budgets.DeleteBudget(ctx, organizationID, id); err != nil { + return err + } + return p.record(ctx, organizationID, actor, id, "DELETED", gameBudgetModel(current), nil) + }) + return gameBudgetError(err) +} + +// CreateNextPeriods gives each global budget running today its successor for the +// following month, when there is none yet (§12). Running it again creates nothing +// more. It returns how many budgets it created. +func (p *GameBudgetProcessor) CreateNextPeriods(ctx context.Context) (int, error) { + var created []entities.GameBudget + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + var err error + created, err = p.budgets.CreateNextGlobalBudgets(ctx, walletDay(p.now())) + if err != nil { + return err + } + for i := range created { + err := p.audit.Record(ctx, AuditEntry{ + OrganizationID: created[i].OrganizationID, ActorType: constants.AuditActorSystem, + EntityType: constants.AuditEntityGameBudget, EntityID: created[i].ID, Action: "CREATED", + After: gameBudgetModel(&created[i]), Source: constants.AuditSourceBudgetPeriodJob, + }) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return 0, err + } + return len(created), nil +} + +// checkGlobalPeriod refuses a global budget sharing a day with another of the +// organization. The lock makes two saves at once see each other. +func (p *GameBudgetProcessor) checkGlobalPeriod(ctx context.Context, b *entities.GameBudget, except *uuid.UUID) error { + if b.Scope != constants.GameBudgetScopeGlobal { + return nil + } + if err := p.budgets.LockGlobalBudgets(ctx, b.OrganizationID); err != nil { + return err + } + overlaps, err := p.budgets.GlobalBudgetOverlaps(ctx, b.OrganizationID, b.PeriodStart, b.PeriodEnd, except) + if err != nil { + return err + } + if overlaps { + return enakGameRejected("another global budget already covers part of this period") + } + return nil +} + +func gameBudgetError(err error) error { + switch { + case errors.Is(err, repository.ErrGameBudgetPeriodTaken): + return enakGameRejected("another global budget already starts on that day") + case errors.Is(err, repository.ErrGameBudgetInUse): + return enakGameRejected("rewards or events use this budget, so it cannot be deleted") + } + return err +} + +func gameBudgetFromInput(in models.GameBudgetInput) (*entities.GameBudget, error) { + scope := strings.ToUpper(strings.TrimSpace(in.Scope)) + name := strings.TrimSpace(in.Name) + if scope != constants.GameBudgetScopeGlobal && scope != constants.GameBudgetScopeEvent { + return nil, enakGameRejected("scope must be GLOBAL or EVENT") + } + if name == "" || len(name) > 255 { + return nil, enakGameRejected("name is required, at most 255 characters") + } + start, err := time.Parse("2006-01-02", strings.TrimSpace(in.PeriodStart)) + if err != nil { + return nil, enakGameRejected("period_start must be a date like 2026-10-01") + } + end, err := time.Parse("2006-01-02", strings.TrimSpace(in.PeriodEnd)) + if err != nil { + return nil, enakGameRejected("period_end must be a date like 2026-10-31") + } + if end.Before(start) { + return nil, enakGameRejected("period_end cannot be before period_start") + } + if in.Amount <= 0 { + return nil, enakGameRejected("amount must be more than 0") + } + t := in.Thresholds + for field, v := range map[string]*int64{"warning": t.Warning, "critical": t.Critical} { + if v != nil && (*v < 0 || *v > 100) { + return nil, enakGameRejected("thresholds.%s must be between 0 and 100", field) + } + } + if t.Warning != nil && t.Critical != nil && *t.Warning > *t.Critical { + return nil, enakGameRejected("thresholds.warning cannot be above thresholds.critical") + } + thresholds, err := json.Marshal(t) + if err != nil { + return nil, err + } + return &entities.GameBudget{ + Scope: scope, Name: name, PeriodStart: start, PeriodEnd: end, Amount: in.Amount, + Thresholds: entities.JSONDocument(thresholds), + }, nil +} + +func gameBudgetModel(b *entities.GameBudget) *models.GameBudget { + m := &models.GameBudget{ + ID: b.ID, Scope: b.Scope, Name: b.Name, + PeriodStart: b.PeriodStart.Format("2006-01-02"), PeriodEnd: b.PeriodEnd.Format("2006-01-02"), + Amount: b.Amount, CreatedBy: b.CreatedBy, CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt, + } + if len(b.Thresholds) > 0 { + _ = json.Unmarshal(b.Thresholds, &m.Thresholds) + } + return m +} diff --git a/internal/processor/game_event_db_test.go b/internal/processor/game_event_db_test.go new file mode 100644 index 0000000..7830df9 --- /dev/null +++ b/internal/processor/game_event_db_test.go @@ -0,0 +1,289 @@ +package processor + +import ( + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// EnakGame events against Postgres (docs/rfc-enakgame.md §5.5, §7.2 step 7). Needs +// TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go. + +func (e *enakGameEnv) eventBudget(org uuid.UUID, name string) *models.GameBudget { + e.t.Helper() + b, err := e.budgets.CreateBudget(e.ctx(), org, e.manager, models.GameBudgetInput{ + Scope: "EVENT", Name: name, PeriodStart: "2026-01-01", PeriodEnd: "2026-12-31", Amount: 10_000_000, + }) + require.NoError(e.t, err) + return b +} + +func (e *enakGameEnv) event(in models.GameEventInput) *models.GameEvent { + e.t.Helper() + if in.Name == "" { + in.Name = "Event" + } + if in.Slug == "" { + in.Slug = "event-" + uuid.NewString()[:8] + } + if in.StartAt.IsZero() { + in.StartAt, in.EndAt = e.now.Add(-time.Hour), e.now.Add(24*time.Hour) + } + if in.Status == "" { + in.Status = constants.GameEventStatusActive + } + ev, err := e.events.CreateEvent(e.ctx(), e.orgA, e.manager, in) + require.NoError(e.t, err) + return ev +} + +// EG-702 +func TestGameEventAdmin(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + game := e.playableGame(e.orgA, "runner", 1) + theirGame := e.playableGame(e.orgB, "theirs", 1) + budget := e.eventBudget(e.orgA, "Ramadan") + theirBudget := e.eventBudget(e.orgB, "Theirs") + var global string + require.NoError(t, e.db.Raw(`SELECT id::text FROM game_budgets WHERE organization_id = ? AND scope = 'GLOBAL'`, e.orgA).Scan(&global).Error) + valid := func() models.GameEventInput { + return models.GameEventInput{Name: "Ramadan", Slug: "ramadan", StartAt: e.now, EndAt: e.now.Add(time.Hour), + Multiplier: ptr(2.0), BudgetID: budget.ID, GameIDs: []uuid.UUID{game.ID}} + } + + for name, mutate := range map[string]func(*models.GameEventInput){ + "global budget": func(in *models.GameEventInput) { in.BudgetID = uuid.MustParse(global) }, + "another org's budget": func(in *models.GameEventInput) { in.BudgetID = theirBudget.ID }, + "another org's game": func(in *models.GameEventInput) { in.GameIDs = []uuid.UUID{theirGame.ID} }, + "no games": func(in *models.GameEventInput) { in.GameIDs = nil }, + "ends before start": func(in *models.GameEventInput) { in.EndAt = in.StartAt }, + "nothing to add": func(in *models.GameEventInput) { in.Multiplier = ptr(1.0) }, + "multiplier below 1": func(in *models.GameEventInput) { in.Multiplier = ptr(0.5) }, + "three decimals": func(in *models.GameEventInput) { in.Multiplier = ptr(1.125) }, + "zero bonus": func(in *models.GameEventInput) { in.Bonus = ptr(int64(0)) }, + "unknown timezone": func(in *models.GameEventInput) { in.Timezone = "Mars/Olympus" }, + "ended on create": func(in *models.GameEventInput) { in.Status = "ENDED" }, + } { + in := valid() + mutate(&in) + _, err := e.events.CreateEvent(ctx, e.orgA, e.manager, in) + assert.ErrorIs(t, err, ErrEnakGameRejected, name) + } + + ev, err := e.events.CreateEvent(ctx, e.orgA, e.manager, valid()) + require.NoError(t, err) + assert.Equal(t, constants.GameEventStatusDraft, ev.Status) + assert.Equal(t, "Asia/Jakarta", ev.Timezone) + assert.Equal(t, []uuid.UUID{game.ID}, ev.GameIDs) + _, err = e.events.CreateEvent(ctx, e.orgA, e.manager, valid()) + assert.ErrorIs(t, err, ErrEnakGameRejected, "slug in use") + _, err = e.events.GetEvent(ctx, e.orgB, ev.ID) + assert.ErrorIs(t, err, repository.ErrGameEventNotFound) + + in := GameEventInputFrom(ev) + in.Bonus = ptr(int64(5)) + updated, err := e.events.UpdateEvent(ctx, e.orgA, e.manager, ev.ID, in) + require.NoError(t, err) + assert.EqualValues(t, 5, *updated.Bonus) + assert.EqualValues(t, 2, *updated.Multiplier) + + for _, step := range []struct { + to string + ok bool + }{{"ENDED", false}, {"ACTIVE", true}, {"DRAFT", false}, {"ENDED", true}, {"ACTIVE", false}, {"CANCELLED", false}} { + _, err := e.events.SetEventStatus(ctx, e.orgA, e.manager, ev.ID, models.GameEventStatusInput{Status: step.to}) + if step.ok { + assert.NoError(t, err, step.to) + } else { + assert.ErrorIs(t, err, ErrEnakGameRejected, step.to) + } + } + _, err = e.events.UpdateEvent(ctx, e.orgA, e.manager, ev.ID, in) + assert.ErrorIs(t, err, ErrEnakGameRejected, "an ended event never changes") + assert.Equal(t, []string{"CREATED", "UPDATED", "STATUS_CHANGED", "STATUS_CHANGED"}, e.auditActions(constants.AuditEntityGameEvent, ev.ID)) + + // A budget an event points at cannot be deleted. + assert.ErrorIs(t, e.budgets.DeleteBudget(ctx, e.orgA, e.manager, budget.ID), ErrEnakGameRejected) +} + +// EG-703, EG-704 +func TestGameEvent_RewardsAndLimits(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + ten := e.gameWith("ten", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 0) + other := e.gameWith("other", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 0) + e.coins(e.alice, 100, nil) + e.coins(e.bob, 100, nil) + var global string + require.NoError(t, e.db.Raw(`SELECT id::text FROM game_budgets WHERE organization_id = ? AND scope = 'GLOBAL'`, e.orgA).Scan(&global).Error) + play := func(customer, gameID uuid.UUID) (*models.GameSessionCompletion, uuid.UUID) { + t.Helper() + s, err := e.sessions.Start(ctx, customer, gameID, uuid.NewString()) + require.NoError(t, err) + out, err := e.sessions.Complete(ctx, customer, s.SessionID, models.GameSessionCompleteInput{}) + require.NoError(t, err) + return out, s.SessionID + } + rewards := func(session uuid.UUID) map[string]int64 { + t.Helper() + var rows []struct { + BudgetID string + Amount int64 + Ledger int64 + } + require.NoError(t, e.db.Raw(`SELECT r.budget_id::text AS budget_id, r.amount, t.amount AS ledger + FROM game_session_rewards r JOIN wallet_transactions t ON t.id = r.wallet_transaction_id + WHERE r.session_id = ? AND t.type = 'GAME_REWARD'`, session).Scan(&rows).Error) + out := map[string]int64{} + for _, row := range rows { + assert.Equal(t, row.Amount, row.Ledger) + out[row.BudgetID] = row.Amount + } + return out + } + + // PRD §7: 10 Coin with Ramadan 2x is two GAME_REWARD rows, 10 from each budget. + ramadanBudget := e.eventBudget(e.orgA, "Ramadan") + ramadan := e.event(models.GameEventInput{Name: "Ramadan", Multiplier: ptr(2.0), BudgetID: ramadanBudget.ID, GameIDs: []uuid.UUID{ten.ID}, Priority: 10}) + out, id := play(e.alice, ten.ID) + assert.EqualValues(t, 20, out.RewardTotal) + assert.Equal(t, models.GameSessionRewardParts{Base: 10, Event: 10}, out.Reward) + assert.Equal(t, map[string]int64{global: 10, ramadanBudget.ID.String(): 10}, rewards(id)) + + // The other game is not in the event. + out, id = play(e.alice, other.ID) + assert.EqualValues(t, 10, out.RewardTotal) + assert.Equal(t, map[string]int64{global: 10}, rewards(id)) + + // Events that are not running change nothing: not started, over, a draft. + later := e.eventBudget(e.orgA, "Later") + e.event(models.GameEventInput{Bonus: ptr(int64(50)), BudgetID: later.ID, GameIDs: []uuid.UUID{other.ID}, StartAt: e.now.Add(time.Hour), EndAt: e.now.Add(2 * time.Hour)}) + e.event(models.GameEventInput{Bonus: ptr(int64(50)), BudgetID: later.ID, GameIDs: []uuid.UUID{other.ID}, StartAt: e.now.Add(-2 * time.Hour), EndAt: e.now.Add(-time.Minute)}) + e.event(models.GameEventInput{Bonus: ptr(int64(50)), BudgetID: later.ID, GameIDs: []uuid.UUID{other.ID}, Status: "DRAFT"}) + out, _ = play(e.alice, other.ID) + assert.EqualValues(t, 10, out.RewardTotal) + + // The customer list shows the event on its game only. + games, err := e.sessions.ListGames(ctx, e.alice) + require.NoError(t, err) + for _, g := range games { + if g.ID == ten.ID { + require.Len(t, g.Events, 1) + assert.Equal(t, "Ramadan", g.Events[0].Name) + assert.EqualValues(t, 2, *g.Events[0].Multiplier) + assert.Equal(t, ramadan.EndAt.Unix(), g.Events[0].EndAt.Unix()) + } else { + assert.Empty(t, g.Events, g.Slug) + } + } + + // A second event on the same game: its bonus is counted on its own; its limit over + // the whole event cuts it, then a customer's daily limit within it. + bonusBudget := e.eventBudget(e.orgA, "Bonus") + bonus := e.event(models.GameEventInput{Name: "Bonus", Bonus: ptr(int64(5)), BudgetID: bonusBudget.ID, GameIDs: []uuid.UUID{ten.ID}, + RewardLimit: ptr(int64(12)), UserDailyLimit: ptr(int64(7))}) + out, id = play(e.bob, ten.ID) + assert.EqualValues(t, 25, out.RewardTotal, "10 + 10 + 5") + assert.Equal(t, map[string]int64{global: 10, ramadanBudget.ID.String(): 10, bonusBudget.ID.String(): 5}, rewards(id)) + out, _ = play(e.bob, ten.ID) + assert.EqualValues(t, 22, out.RewardTotal, "Bob has 2 left of his 7 today") + assert.Equal(t, []string{"EVENT_USER_DAILY"}, out.LimitedBy) + out, _ = play(e.alice, ten.ID) + assert.EqualValues(t, 25, out.RewardTotal, "Alice has her own 7") + out, _ = play(e.alice, ten.ID) + assert.EqualValues(t, 20, out.RewardTotal, "the event's 12 are given out: 5 + 2 + 5") + assert.Equal(t, []string{"EVENT_LIMIT"}, out.LimitedBy) + counter := repository.NewGameRewardCounterRepository(e.db) + got, err := counter.Get(ctx, e.orgA, constants.GameRewardScopeEvent, bonus.ID, eventLifetimeDay) + require.NoError(t, err) + assert.EqualValues(t, 12, got) + + // The daily limit of the customer cuts events before the base, and the event + // counters give back what was cut. + carla := e.newCustomer(e.orgA) + e.coins(carla, 10, nil) + settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), e.txm) + t.Cleanup(func() { + e.db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, e.orgA) + e.db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, e.orgA) + }) + s, err := settings.Organization(ctx, e.orgA) + require.NoError(t, err) + s.EnakGame.UserDailyLimit = 14 + _, _, err = settings.UpdateOrganization(ctx, e.orgA, e.manager, *s) + require.NoError(t, err) + before, err := counter.Get(ctx, e.orgA, constants.GameRewardScopeEvent, ramadan.ID, eventLifetimeDay) + require.NoError(t, err) + out, id = play(carla, ten.ID) + assert.EqualValues(t, 14, out.RewardTotal) + assert.Equal(t, models.GameSessionRewardParts{Base: 10, Event: 4}, out.Reward, "the base first, the event gets what is left") + assert.Equal(t, []string{"EVENT_LIMIT", "USER_DAILY"}, out.LimitedBy, "Bonus is used up; then her daily 14") + assert.Equal(t, map[string]int64{global: 10, ramadanBudget.ID.String(): 4}, rewards(id)) + after, err := counter.Get(ctx, e.orgA, constants.GameRewardScopeEvent, ramadan.ID, eventLifetimeDay) + require.NoError(t, err) + assert.EqualValues(t, 4, after-before, "the event counter holds what was paid") + + // D5: EnakPoint from the event's part of a reward cost the event's budget when + // redeemed, the base part the global budget. + s.EnakGame.UserDailyLimit = 0 + _, _, err = settings.UpdateOrganization(ctx, e.orgA, e.manager, *s) + require.NoError(t, err) + dina := e.newCustomer(e.orgA) + e.coins(dina, 2, nil) + _, id = play(dina, ten.ID) + assert.Equal(t, map[string]int64{global: 10, ramadanBudget.ID.String(): 10}, rewards(id), "Bonus is used up") + e.exchangeCoins(dina, 20) + voucher := e.staticVoucher(5, 2_000, 20) + res, err := e.redeemVoucher(dina, voucher.ID, "event-cost") + require.NoError(t, err) + r := ramadanBudget.ID.String() + assert.ElementsMatch(t, []costRow{ + {BudgetID: &global, SourceType: "GAME_REWARD", Points: 10, Cost: 1_000}, + {BudgetID: &r, SourceType: "GAME_REWARD", Points: 10, Cost: 1_000}, + }, e.costs(res.ID)) +} + +// Customers completing at once never take an event past its limit. +func TestGameEvent_LimitHoldsUnderConcurrency(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + game := e.gameWith("rush", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 0) + budget := e.eventBudget(e.orgA, "Rush") + e.event(models.GameEventInput{Bonus: ptr(int64(5)), BudgetID: budget.ID, GameIDs: []uuid.UUID{game.ID}, RewardLimit: ptr(int64(12))}) + + sessions := map[uuid.UUID]uuid.UUID{} + for i := 0; i < 6; i++ { + c := e.newCustomer(e.orgA) + e.coins(c, 2, nil) + s, err := e.sessions.Start(ctx, c, game.ID, "rush") + require.NoError(t, err) + sessions[c] = s.SessionID + } + var wg sync.WaitGroup + begin := make(chan struct{}) + for c, id := range sessions { + c, id := c, id + wg.Add(1) + go func() { + defer wg.Done() + <-begin + _, err := e.sessions.Complete(ctx, c, id, models.GameSessionCompleteInput{}) + assert.NoError(t, err) + }() + } + close(begin) + wg.Wait() + assert.Equal(t, int64(12), e.count(`SELECT COALESCE(SUM(amount), 0) FROM game_session_rewards WHERE budget_id = ?`, budget.ID)) + assert.Equal(t, int64(60), e.count(`SELECT COALESCE(SUM(r.amount), 0) FROM game_session_rewards r JOIN game_budgets b ON b.id = r.budget_id + WHERE b.scope = 'GLOBAL' AND r.session_id IN (SELECT id FROM game_sessions WHERE game_id = ?)`, game.ID), "every base paid") +} diff --git a/internal/processor/game_event_processor.go b/internal/processor/game_event_processor.go new file mode 100644 index 0000000..0ac714d --- /dev/null +++ b/internal/processor/game_event_processor.go @@ -0,0 +1,306 @@ +package processor + +import ( + "context" + "errors" + "math" + "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" +) + +// gameEventMaxMultiplier is the largest multiplier NUMERIC(5,2) holds. +const gameEventMaxMultiplier = 999.99 + +// GameEventProcessor manages an organization's EnakGame events +// (docs/rfc-enakgame.md §5.5, §11). Every change is audited in its transaction. +type GameEventProcessor struct { + events repository.GameEventRepository + games repository.EnakGameRepository + budgets repository.GameBudgetRepository + audit *AuditLogger + tx TxRunner +} + +func NewGameEventProcessor(events repository.GameEventRepository, games repository.EnakGameRepository, budgets repository.GameBudgetRepository, audit *AuditLogger, tx TxRunner) *GameEventProcessor { + return &GameEventProcessor{events: events, games: games, budgets: budgets, audit: audit, tx: tx} +} + +func (p *GameEventProcessor) record(ctx context.Context, organizationID, actor, id uuid.UUID, action string, before, after any, reason *string) error { + return p.audit.Record(ctx, AuditEntry{ + OrganizationID: organizationID, ActorType: constants.AuditActorUser, ActorID: &actor, + EntityType: constants.AuditEntityGameEvent, EntityID: id, Action: action, + Before: before, After: after, Reason: reason, Source: constants.AuditSourceAdminAPI, + }) +} + +// GameEventInputFrom is an event's current values as an input, for a change that only +// sends the fields it changes. +func GameEventInputFrom(e *models.GameEvent) models.GameEventInput { + return models.GameEventInput{ + Name: e.Name, Slug: e.Slug, Description: e.Description, BannerURL: e.BannerURL, StartAt: e.StartAt, EndAt: e.EndAt, + Timezone: e.Timezone, Priority: e.Priority, Multiplier: e.Multiplier, Bonus: e.Bonus, BudgetID: e.BudgetID, + RewardLimit: e.RewardLimit, UserDailyLimit: e.UserDailyLimit, GameIDs: e.GameIDs, Status: e.Status, + } +} + +func (p *GameEventProcessor) CreateEvent(ctx context.Context, organizationID, actor uuid.UUID, in models.GameEventInput) (*models.GameEvent, error) { + status := strings.ToUpper(strings.TrimSpace(in.Status)) + if status == "" { + status = constants.GameEventStatusDraft + } + if status != constants.GameEventStatusDraft && status != constants.GameEventStatusActive { + return nil, enakGameRejected("a new event must be DRAFT or ACTIVE") + } + event := &entities.GameEvent{OrganizationID: organizationID, Status: status} + gameIDs, err := applyGameEventInput(event, in) + if err != nil { + return nil, err + } + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if err := p.checkReferences(ctx, organizationID, event.BudgetID, gameIDs); err != nil { + return err + } + if err := p.events.CreateEvent(ctx, event, gameIDs); err != nil { + return err + } + return p.record(ctx, organizationID, actor, event.ID, "CREATED", nil, gameEventModel(event, gameIDs), nil) + }) + if err != nil { + return nil, gameEventError(err) + } + return gameEventModel(event, gameIDs), nil +} + +func (p *GameEventProcessor) GetEvent(ctx context.Context, organizationID, id uuid.UUID) (*models.GameEvent, error) { + event, err := p.events.GetEvent(ctx, organizationID, id) + if err != nil { + return nil, err + } + games, err := p.events.EventGames(ctx, []uuid.UUID{id}) + if err != nil { + return nil, err + } + return gameEventModel(event, games[id]), nil +} + +func (p *GameEventProcessor) ListEvents(ctx context.Context, organizationID uuid.UUID, q models.GameEventListQuery) (*models.PaginatedResponse[models.GameEvent], error) { + page, limit := enakGamePage(q.Page, q.Limit) + var statuses []string + if q.Status != "" { + status := strings.ToUpper(strings.TrimSpace(q.Status)) + if !isGameEventStatus(status) { + return nil, enakGameRejected("unknown status %q", q.Status) + } + statuses = []string{status} + } + events, total, err := p.events.ListEvents(ctx, repository.GameEventFilter{ + OrganizationID: organizationID, Statuses: statuses, Offset: (page - 1) * limit, Limit: limit, + }) + if err != nil { + return nil, err + } + ids := make([]uuid.UUID, 0, len(events)) + for _, e := range events { + ids = append(ids, e.ID) + } + games, err := p.events.EventGames(ctx, ids) + if err != nil { + return nil, err + } + items := make([]models.GameEvent, 0, len(events)) + for i := range events { + items = append(items, *gameEventModel(&events[i], games[events[i].ID])) + } + return &models.PaginatedResponse[models.GameEvent]{Data: items, Pagination: enakGamePagination(page, limit, total)}, nil +} + +// UpdateEvent changes everything but the status. An event that ended or was cancelled +// never changes. +func (p *GameEventProcessor) UpdateEvent(ctx context.Context, organizationID, actor, id uuid.UUID, in models.GameEventInput) (*models.GameEvent, error) { + var after *models.GameEvent + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + event, err := p.events.LockEvent(ctx, organizationID, id) + if err != nil { + return err + } + if event.Status == constants.GameEventStatusEnded || event.Status == constants.GameEventStatusCancelled { + return enakGameRejected("an event that ended or was cancelled cannot change") + } + games, err := p.events.EventGames(ctx, []uuid.UUID{id}) + if err != nil { + return err + } + before := gameEventModel(event, games[id]) + gameIDs, err := applyGameEventInput(event, in) + if err != nil { + return err + } + if err := p.checkReferences(ctx, organizationID, event.BudgetID, gameIDs); err != nil { + return err + } + if err := p.events.UpdateEvent(ctx, event, gameIDs); err != nil { + return err + } + saved, err := p.events.GetEvent(ctx, organizationID, id) + if err != nil { + return err + } + after = gameEventModel(saved, gameIDs) + return p.record(ctx, organizationID, actor, id, "UPDATED", before, after, nil) + }) + if err != nil { + return nil, gameEventError(err) + } + return after, nil +} + +// SetEventStatus moves an event: DRAFT to ACTIVE or CANCELLED, ACTIVE to ENDED or +// CANCELLED. ENDED and CANCELLED are final. +func (p *GameEventProcessor) SetEventStatus(ctx context.Context, organizationID, actor, id uuid.UUID, in models.GameEventStatusInput) (*models.GameEvent, error) { + status := strings.ToUpper(strings.TrimSpace(in.Status)) + if !isGameEventStatus(status) { + return nil, enakGameRejected("status must be DRAFT, ACTIVE, ENDED or CANCELLED") + } + if err := validateReason(in.Reason); err != nil { + return nil, err + } + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + event, err := p.events.LockEvent(ctx, organizationID, id) + if err != nil { + return err + } + if event.Status == status { + return nil + } + allowed := map[string][]string{ + constants.GameEventStatusDraft: {constants.GameEventStatusActive, constants.GameEventStatusCancelled}, + constants.GameEventStatusActive: {constants.GameEventStatusEnded, constants.GameEventStatusCancelled}, + }[event.Status] + if !containsString(allowed, status) { + return enakGameRejected("an event cannot go from %s to %s", event.Status, status) + } + if err := p.events.SetEventStatus(ctx, organizationID, id, status); err != nil { + return err + } + return p.record(ctx, organizationID, actor, id, "STATUS_CHANGED", + map[string]string{"status": event.Status}, map[string]string{"status": status}, in.Reason) + }) + if err != nil { + return nil, err + } + return p.GetEvent(ctx, organizationID, id) +} + +// checkReferences refuses a budget that is not an EVENT budget of the organization, +// and games that are not the organization's or are archived. +func (p *GameEventProcessor) checkReferences(ctx context.Context, organizationID, budgetID uuid.UUID, gameIDs []uuid.UUID) error { + budget, err := p.budgets.GetBudget(ctx, organizationID, budgetID) + if errors.Is(err, repository.ErrGameBudgetNotFound) { + return enakGameRejected("budget_id is not a budget of this organization") + } + if err != nil { + return err + } + if budget.Scope != constants.GameBudgetScopeEvent { + return enakGameRejected("an event is paid by an EVENT budget, not a %s one", budget.Scope) + } + for _, gameID := range gameIDs { + game, err := p.games.GetGame(ctx, organizationID, gameID) + if errors.Is(err, repository.ErrEnakGameNotFound) { + return enakGameRejected("game %s is not a game of this organization", gameID) + } + if err != nil { + return err + } + if game.Status == constants.GameStatusArchived { + return enakGameRejected("game %s is archived", gameID) + } + } + return nil +} + +func gameEventError(err error) error { + if errors.Is(err, repository.ErrGameEventSlugTaken) { + return enakGameRejected("another event already uses this slug") + } + return err +} + +func isGameEventStatus(s string) bool { + switch s { + case constants.GameEventStatusDraft, constants.GameEventStatusActive, constants.GameEventStatusEnded, constants.GameEventStatusCancelled: + return true + } + return false +} + +// applyGameEventInput checks an input and copies it onto the event, leaving its +// organization and status alone. It returns the games, without repeats. +func applyGameEventInput(e *entities.GameEvent, in models.GameEventInput) ([]uuid.UUID, error) { + name, slug := strings.TrimSpace(in.Name), strings.TrimSpace(in.Slug) + timezone := strings.TrimSpace(in.Timezone) + if timezone == "" { + timezone = "Asia/Jakarta" + } + switch { + case name == "" || len(name) > 255: + return nil, enakGameRejected("name is required, at most 255 characters") + case len(slug) > 100 || !enakGameSlugPattern.MatchString(slug): + return nil, enakGameRejected("slug must be lowercase letters, digits and single dashes, at most 100 characters") + case in.StartAt.IsZero() || !in.EndAt.After(in.StartAt): + return nil, enakGameRejected("start_at is required and end_at must be after it") + case in.Multiplier != nil && (*in.Multiplier < 1 || *in.Multiplier > gameEventMaxMultiplier): + return nil, enakGameRejected("multiplier must be between 1 and %g", gameEventMaxMultiplier) + case in.Multiplier != nil && math.Abs(*in.Multiplier*100-math.Round(*in.Multiplier*100)) > 1e-9: + return nil, enakGameRejected("multiplier must have at most two decimals") + case in.Bonus != nil && *in.Bonus < 1: + return nil, enakGameRejected("bonus must be at least 1") + case (in.Multiplier == nil || *in.Multiplier == 1) && in.Bonus == nil: + return nil, enakGameRejected("an event needs a multiplier above 1 or a bonus") + case in.RewardLimit != nil && *in.RewardLimit < 1: + return nil, enakGameRejected("reward_limit must be at least 1, or left out for no limit") + case in.UserDailyLimit != nil && *in.UserDailyLimit < 1: + return nil, enakGameRejected("user_daily_limit must be at least 1, or left out for no limit") + case in.BudgetID == uuid.Nil: + return nil, enakGameRejected("budget_id is required") + case len(in.GameIDs) == 0: + return nil, enakGameRejected("game_ids needs at least one game") + case in.BannerURL != nil && len(*in.BannerURL) > 500: + return nil, enakGameRejected("banner_url must be at most 500 characters") + } + if _, err := time.LoadLocation(timezone); err != nil || len(timezone) > 50 { + return nil, enakGameRejected("timezone %q is not a known time zone", timezone) + } + seen := map[uuid.UUID]bool{} + var gameIDs []uuid.UUID + for _, id := range in.GameIDs { + if !seen[id] { + seen[id] = true + gameIDs = append(gameIDs, id) + } + } + e.Name, e.Slug, e.Description, e.BannerURL = name, slug, in.Description, in.BannerURL + e.StartAt, e.EndAt, e.Timezone, e.Priority = in.StartAt, in.EndAt, timezone, in.Priority + e.Multiplier, e.Bonus, e.BudgetID = in.Multiplier, in.Bonus, in.BudgetID + e.RewardLimit, e.UserDailyLimit = in.RewardLimit, in.UserDailyLimit + return gameIDs, nil +} + +func gameEventModel(e *entities.GameEvent, gameIDs []uuid.UUID) *models.GameEvent { + if gameIDs == nil { + gameIDs = []uuid.UUID{} + } + return &models.GameEvent{ + ID: e.ID, Name: e.Name, Slug: e.Slug, Description: e.Description, BannerURL: e.BannerURL, StartAt: e.StartAt, + EndAt: e.EndAt, Timezone: e.Timezone, Status: e.Status, Priority: e.Priority, Multiplier: e.Multiplier, + Bonus: e.Bonus, BudgetID: e.BudgetID, RewardLimit: e.RewardLimit, UserDailyLimit: e.UserDailyLimit, + GameIDs: gameIDs, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, + } +} diff --git a/internal/processor/game_play_processor_db_test.go b/internal/processor/game_play_processor_db_test.go index ef1cb6d..095a916 100644 --- a/internal/processor/game_play_processor_db_test.go +++ b/internal/processor/game_play_processor_db_test.go @@ -18,7 +18,8 @@ import ( func TestPlayGame_AgainstPostgres(t *testing.T) { db, _, player, _ := walletMoveDB(t) gameID, prizeID := uuid.New(), uuid.New() - require.NoError(t, db.Exec(`INSERT INTO games (id, name, type, metadata) VALUES (?, 'Raffle', 'RAFFLE', '{"coin_cost": 2}')`, gameID).Error) + // An old game: archived for EnakGame (000103) but still active for the old spin. + require.NoError(t, db.Exec(`INSERT INTO games (id, name, type, metadata, status, entry_cost) VALUES (?, 'Raffle', 'RAFFLE', '{"coin_cost": 2}', 'ARCHIVED', 2)`, gameID).Error) require.NoError(t, db.Exec(`INSERT INTO game_prizes (id, game_id, name, weight, stock) VALUES (?, ?, 'Tumbler', 1, 1)`, prizeID, gameID).Error) t.Cleanup(func() { db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id = ?)`, player) diff --git a/internal/processor/game_session_complete.go b/internal/processor/game_session_complete.go new file mode 100644 index 0000000..272c038 --- /dev/null +++ b/internal/processor/game_session_complete.go @@ -0,0 +1,369 @@ +package processor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// gameResultDataLimit bounds the free-form data a client may attach to a result. +const gameResultDataLimit = 16 * 1024 + +// gameRewardBreakdown is how a session's reward was reached, kept in +// game_sessions.reward_breakdown. It is internal: the customer only sees the totals. +type gameRewardBreakdown struct { + RewardConfigID uuid.UUID `json:"reward_config_id"` + ConfigVersion int `json:"config_version"` + RewardType string `json:"reward_type"` + // Why the result was not believed, if it was not (§7.2 step 5). + Rejections []string `json:"rejections,omitempty"` + Base int64 `json:"base"` + Detail map[string]any `json:"detail,omitempty"` + MaxReward int64 `json:"max_reward"` + Capped bool `json:"capped"` + // The events running on the game when it completed, highest priority first. + Events []uuid.UUID `json:"events,omitempty"` + // The limits that cut the reward (§9), in the order they were applied. + Guard []gameGuardCut `json:"guard,omitempty"` + LimitedBy []string `json:"limited_by,omitempty"` + // The base and what each event adds, each paid by its budget (D6). + Components []gameRewardComponent `json:"components"` + Total int64 `json:"total"` +} + +type gameRewardComponent struct { + // BASE or EVENT. + Kind string `json:"kind"` + BudgetID uuid.UUID `json:"budget_id"` + // EVENT only: the event and what it applied. + EventID *uuid.UUID `json:"event_id,omitempty"` + Multiplier *float64 `json:"multiplier,omitempty"` + Bonus *int64 `json:"bonus,omitempty"` + // Before the cap and the limits, and what was paid. + Earned int64 `json:"earned"` + Amount int64 `json:"amount"` + LimitedBy []string `json:"limited_by,omitempty"` +} + +// gameGuardCut is one limit that cut a reward: how much it allowed of what was asked. +type gameGuardCut struct { + Limit string `json:"limit"` + // For an event's own limits. + EventID *uuid.UUID `json:"event_id,omitempty"` + Value int64 `json:"value"` + Asked int64 `json:"asked"` + Allowed int64 `json:"allowed"` +} + +// The limits of §5.10 and §9, by the name the breakdown and the response give them. +const ( + gameLimitUserDaily = "USER_DAILY" + gameLimitGameDaily = "GAME_DAILY" + gameLimitGlobalDaily = "GLOBAL_DAILY" +) + +// storedGameResult is what is kept of the client's result: the data only (P1). +type storedGameResult struct { + Score *int64 `json:"score,omitempty"` + Outcome *string `json:"outcome,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +// Complete ends a session with the client's result and pays its reward, in one +// transaction (docs/rfc-enakgame.md §7.2). The backend alone decides the reward +// (P1): the result is checked against the game's result_rules, priced with the +// configuration frozen at start, and capped by its max_reward. A result that is not +// believed completes the session with no reward and flags it; the entry cost stays +// spent. +// +// Completing again returns the first completion. When the game was turned off during +// the play, the entry cost is refunded instead. A failure that is not the customer's +// marks the session, in a transaction of its own, so the session job refunds it once +// it expires (§7.3). +func (p *GameSessionProcessor) Complete(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) (*models.GameSessionCompletion, error) { + if len(in.Data) > gameResultDataLimit { + return nil, gameSessionRejected("data must be at most %d bytes", gameResultDataLimit) + } + session, err := p.complete(ctx, customerID, sessionID, in) + if errors.Is(err, errGameSessionMoved) { + // The session job finished it between our read and our update: answer with + // what it is now. + session, err = p.complete(ctx, customerID, sessionID, in) + } + if err != nil { + if !isGameSessionBusinessError(err) { + p.markCompletionFailed(ctx, customerID, sessionID) + } + return nil, err + } + balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now()) + if err != nil { + return nil, err + } + out := &models.GameSessionCompletion{ + SessionID: session.ID, + Status: session.Status, + RefundReason: session.RefundReason, + RewardTotal: session.RewardTotal, + CoinBalance: balances[constants.WalletCurrencyCoin], + } + // Read back from the stored breakdown, so a repeated completion answers the same. + if len(session.RewardBreakdown) > 0 { + var stored gameRewardBreakdown + if err := json.Unmarshal(session.RewardBreakdown, &stored); err == nil { + out.LimitedBy = stored.LimitedBy + out.Reward = rewardParts(stored) + } + } + return out, nil +} + +func (p *GameSessionProcessor) complete(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) (*entities.GameSession, error) { + var session *entities.GameSession + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if err := p.wallet.LockWallet(ctx, customerID); err != nil { + return err + } + var err error + session, err = p.sessions.GetCustomerSession(ctx, customerID, sessionID) + if err != nil { + return err + } + switch session.Status { + case constants.GameSessionStatusCompleted, constants.GameSessionStatusRefunded: + // Already settled: the same answer again (PRD §20). + return nil + case constants.GameSessionStatusExpired: + return gameSessionRejected("the session has expired") + } + now := p.now() + if now.After(session.ExpiresAt) { + // Left for the session job, which knows whether to refund it. + return gameSessionRejected("the session has expired") + } + + game, err := p.games.GetGame(ctx, session.OrganizationID, session.GameID) + if err != nil { + return err + } + if game.Status != constants.GameStatusActive { + _, err := p.refundLocked(ctx, session, constants.GameSessionRefundGameDeactivated, constants.AuditSourceCustomerAPI) + return err + } + + config, err := p.games.GetRewardConfig(ctx, session.OrganizationID, session.RewardConfigID) + if err != nil { + return fmt.Errorf("reward config of session %s: %w", session.ID, err) + } + result := SessionResult{Score: in.Score, Outcome: in.Outcome} + breakdown := gameRewardBreakdown{ + RewardConfigID: config.ID, ConfigVersion: config.Version, RewardType: config.RewardType, + MaxReward: config.MaxReward, Components: []gameRewardComponent{}, + } + breakdown.Rejections = ValidateResult(game.ResultRules, result, session.StartedAt, now) + if len(breakdown.Rejections) == 0 { + calculator, err := RewardCalculatorFor(config.RewardType) + if err != nil { + return err + } + breakdown.Base, breakdown.Detail, err = calculator.Calculate(json.RawMessage(config.Rules), result, p.rng) + switch { + case errors.Is(err, ErrRewardResultUnusable): + breakdown.Rejections = append(breakdown.Rejections, err.Error()) + breakdown.Base = 0 + case err != nil: + return err + } + } + var events []entities.GameEvent + if breakdown.Base > 0 { + if events, err = p.events.ActiveEventsForGame(ctx, session.OrganizationID, session.GameID, now); err != nil { + return err + } + for _, e := range events { + breakdown.Events = append(breakdown.Events, e.ID) + } + } + parts, capped := rewardComponents(breakdown.Base, config.MaxReward, events) + breakdown.Capped = capped + + var settings *models.OrganizationLoyaltySettings + if sumComponents(parts) > 0 { + if settings, err = p.settings.Organization(ctx, session.OrganizationID); err != nil { + return err + } + if breakdown.Guard, err = p.guardReward(ctx, session, game, events, settings, now, parts); err != nil { + return err + } + for _, cut := range breakdown.Guard { + if !containsString(breakdown.LimitedBy, cut.Limit) { + breakdown.LimitedBy = append(breakdown.LimitedBy, cut.Limit) + } + } + } + if len(parts) > 0 && parts[0].Amount > 0 { + budget, err := p.rewardBudget(ctx, session, now) + if err != nil { + return err + } + parts[0].BudgetID = budget.ID + } + breakdown.Components = []gameRewardComponent{} + for _, part := range parts { + if part.Amount > 0 { + breakdown.Components = append(breakdown.Components, part) + } + } + total := sumComponents(breakdown.Components) + breakdown.Total = total + + resultDoc, err := json.Marshal(storedGameResult{Score: in.Score, Outcome: in.Outcome, Data: in.Data}) + if err != nil { + return gameSessionRejected("data must be valid JSON") + } + breakdownDoc, err := json.Marshal(breakdown) + if err != nil { + return err + } + flagged := len(breakdown.Rejections) > 0 + moved, err := p.sessions.CompleteSession(ctx, session.ID, repository.GameSessionCompletion{ + Result: entities.JSONDocument(resultDoc), RewardBreakdown: entities.JSONDocument(breakdownDoc), + RewardTotal: total, Flagged: flagged, EndedAt: now, + }) + if err != nil { + return err + } + if !moved { + return errGameSessionMoved + } + session.Status, session.RewardTotal, session.Flagged, session.EndedAt = constants.GameSessionStatusCompleted, total, flagged, &now + session.Result, session.RewardBreakdown = entities.JSONDocument(resultDoc), entities.JSONDocument(breakdownDoc) + + for _, payment := range paymentsByBudget(breakdown.Components) { + if err := p.payReward(ctx, session, game, settings, payment, now); err != nil { + return err + } + } + return nil + }) + return session, err +} + +// rewardBudget is the global budget paying a session's base reward: the one running +// today, or, across a month end without a budget yet, the one the session started +// under. +func (p *GameSessionProcessor) rewardBudget(ctx context.Context, session *entities.GameSession, now time.Time) (*entities.GameBudget, error) { + budget, err := p.budgets.GetGlobalBudgetOn(ctx, session.OrganizationID, walletDay(now)) + if errors.Is(err, repository.ErrGameBudgetNotFound) { + budget, err = p.budgets.GetGlobalBudgetOn(ctx, session.OrganizationID, walletDay(session.StartedAt)) + } + if err != nil { + return nil, fmt.Errorf("no global budget for session %s: %w", session.ID, err) + } + return budget, nil +} + +// payReward credits one budget's part of a reward as its own GAME_REWARD row and lot +// (D6), and records which budget paid it. +func (p *GameSessionProcessor) payReward(ctx context.Context, session *entities.GameSession, game *entities.Game, settings *models.OrganizationLoyaltySettings, c gameRewardComponent, now time.Time) error { + credit, err := p.wallet.Credit(ctx, WalletCreditInput{ + WalletEntry: WalletEntry{ + CustomerID: session.CustomerID, + Currency: constants.WalletCurrencyCoin, + Type: constants.WalletTxTypeGameReward, + Amount: c.Amount, + ReferenceType: constants.WalletRefTypeGameSession, + ReferenceID: session.ID, + Description: truncateDescription("Hadiah " + game.Name), + Metadata: entities.Metadata{"game_id": game.ID.String(), "budget_id": c.BudgetID.String(), "kind": c.Kind}, + IdempotencyKey: fmt.Sprintf("game-reward:%s:%s", session.ID, c.BudgetID), + }, + Lots: []WalletLotInput{{Amount: c.Amount, ExpiresAt: ComputeExpiry(settings.CoinExpiry, now)}}, + }) + if err != nil { + return err + } + return p.sessions.CreateSessionRewards(ctx, []entities.GameSessionReward{{ + SessionID: session.ID, BudgetID: c.BudgetID, Amount: c.Amount, WalletTransactionID: credit.Transaction.ID, + }}) +} + +// applyGuard runs a reward through the daily limits (§9): what one customer may get, +// what the game may give, what the whole organization may give, each counted per day +// in Asia/Jakarta. A reward over a limit is cut to what is left of it, down to 0. It +// returns what may be paid and the limits that cut it. +// +// Every counter takes the reward even when its limit is off, so a limit switched on +// during the day counts what was already given. The counters are taken in a fixed +// order, so two completions never wait on each other's rows the other way round; a +// counter that took more than a later limit allowed gives the difference back. +func (p *GameSessionProcessor) applyGuard(ctx context.Context, session *entities.GameSession, game *entities.Game, settings *models.OrganizationLoyaltySettings, now time.Time, amount int64) (int64, []gameGuardCut, error) { + gameDaily := int64(0) + if game.ResultRules.DailyRewardLimit != nil { + gameDaily = *game.ResultRules.DailyRewardLimit + } + limits := []struct { + name, scope string + scopeID uuid.UUID + value int64 + }{ + {gameLimitUserDaily, constants.GameRewardScopeUser, session.CustomerID, settings.EnakGame.UserDailyLimit}, + {gameLimitGameDaily, constants.GameRewardScopeGame, session.GameID, gameDaily}, + {gameLimitGlobalDaily, constants.GameRewardScopeGlobal, session.OrganizationID, settings.EnakGame.GlobalDailyLimit}, + } + day := walletDay(now) + taken := make([]int64, len(limits)) + var cuts []gameGuardCut + for i, l := range limits { + got, err := p.counters.Consume(ctx, session.OrganizationID, l.scope, l.scopeID, day, amount, l.value) + if err != nil { + return 0, nil, err + } + taken[i] = got + if got < amount { + cuts = append(cuts, gameGuardCut{Limit: l.name, Value: l.value, Asked: amount, Allowed: got}) + amount = got + } + if amount == 0 { + break + } + } + for i, l := range limits { + if extra := taken[i] - amount; extra > 0 { + if err := p.counters.Release(ctx, session.OrganizationID, l.scope, l.scopeID, day, extra); err != nil { + return 0, nil, err + } + } + } + return amount, cuts, nil +} + +// markCompletionFailed records, outside the failed transaction, that completing the +// customer's session failed on a system error (§7.3). +func (p *GameSessionProcessor) markCompletionFailed(ctx context.Context, customerID, sessionID uuid.UUID) { + ctx = repository.DetachTransaction(ctx) + if _, err := p.sessions.GetCustomerSession(ctx, customerID, sessionID); err != nil { + return + } + if _, err := p.sessions.MarkCompletionFailed(ctx, sessionID, p.now()); err != nil { + logger.FromContext(ctx).WithError(err).Error("GameSessionProcessor::Complete -> failed to mark the session") + } +} + +// isGameSessionBusinessError tells a refusal the customer caused from a failure of +// the system. +func isGameSessionBusinessError(err error) bool { + return errors.Is(err, ErrGameSessionRejected) || + errors.Is(err, repository.ErrGameSessionNotFound) || + errors.Is(err, repository.ErrWalletNotFound) +} diff --git a/internal/processor/game_session_processor.go b/internal/processor/game_session_processor.go new file mode 100644 index 0000000..a22b21c --- /dev/null +++ b/internal/processor/game_session_processor.go @@ -0,0 +1,444 @@ +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/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// ErrGameSessionRejected wraps every reason a customer cannot start a game: the key, +// the game, its configuration, the budget, the EnakCoin balance. The message says +// which. +var ErrGameSessionRejected = errors.New("game session refused") + +// errGameSessionMoved rolls back a refund whose session another flow finished first. +var errGameSessionMoved = errors.New("game session is no longer started") + +const ( + // gameSessionKeyLimit keeps the client's Idempotency-Key short enough to fit, with + // the prefix scoping it to the customer, in wallet_transactions.idempotency_key. + gameSessionKeyLimit = 50 + // gameSessionJobBatch is how many sessions the job reads at a time, and + // gameSessionJobBatches how many batches one run goes through at most. + gameSessionJobBatch = 100 + gameSessionJobBatches = 10 + // customerGameListLimit caps the games the customer app gets in one list. + customerGameListLimit = 100 +) + +func gameSessionRejected(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrGameSessionRejected, fmt.Sprintf(format, args...)) +} + +type gameCustomerReader interface { + GetCustomer(ctx context.Context, customerID uuid.UUID) (*repository.WalletMoveCustomer, error) +} + +// GameSessionProcessor starts and completes EnakGame sessions, refunds their entry +// cost when the system is at fault, and ends the ones left behind +// (docs/rfc-enakgame.md §7.1–§7.3). +// +// Every flow locks the customer's wallet before anything else (P4), and a session +// leaves STARTED only through a conditional update (D4), so a refund never races a +// completion or another refund of the same session. +type GameSessionProcessor struct { + customers gameCustomerReader + games repository.EnakGameRepository + sessions repository.GameSessionRepository + budgets repository.GameBudgetRepository + events repository.GameEventRepository + counters repository.GameRewardCounterRepository + settings organizationSettingsReader + spendable spendableReader + wallet *WalletProcessor + audit *AuditLogger + tx TxRunner + rng RewardRNG + now func() time.Time +} + +func NewGameSessionProcessor(customers gameCustomerReader, games repository.EnakGameRepository, sessions repository.GameSessionRepository, + budgets repository.GameBudgetRepository, events repository.GameEventRepository, counters repository.GameRewardCounterRepository, + settings organizationSettingsReader, spendable spendableReader, wallet *WalletProcessor, audit *AuditLogger, tx TxRunner) *GameSessionProcessor { + return &GameSessionProcessor{ + customers: customers, games: games, sessions: sessions, budgets: budgets, events: events, counters: counters, settings: settings, spendable: spendable, + wallet: wallet, audit: audit, tx: tx, rng: CryptoRewardRNG{}, now: time.Now, + } +} + +// Start takes a game's entry cost in EnakCoin and opens a session, in one transaction +// (§7.1, PRD §10.1). The entry cost and the active reward configuration are frozen on +// the session, so changing the game later does not change it. +// +// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns +// the session the first attempt opened and takes nothing more. +func (p *GameSessionProcessor) Start(ctx context.Context, customerID, gameID uuid.UUID, idempotencyKey string) (*models.GameSessionStart, error) { + key := strings.TrimSpace(idempotencyKey) + if key == "" { + return nil, gameSessionRejected("the Idempotency-Key header is required") + } + if len(key) > gameSessionKeyLimit { + return nil, gameSessionRejected("the Idempotency-Key header must be at most %d characters", gameSessionKeyLimit) + } + customer, err := p.customers.GetCustomer(ctx, customerID) + if err != nil { + return nil, err + } + if !customer.IsActive { + return nil, gameSessionRejected("the customer is not active") + } + + walletKey := fmt.Sprintf("game-entry:%s:%s", customerID, key) + var session *entities.GameSession + replayed := false + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if err := p.wallet.LockWallet(ctx, customerID); err != nil { + return err + } + previous, err := p.wallet.FindTransaction(ctx, walletKey) + if err != nil { + return err + } + if previous != nil { + session, err = p.sessions.GetSessionBySpendTransaction(ctx, previous.ID) + if err != nil { + return err + } + if session.GameID != gameID { + return gameSessionRejected("this Idempotency-Key was already used to start another game") + } + replayed = true + return nil + } + + game, err := p.games.GetGame(ctx, customer.OrganizationID, gameID) + if err != nil { + return err + } + if game.Status != constants.GameStatusActive { + return gameSessionRejected("the game is not available") + } + config, err := p.games.GetActiveRewardConfig(ctx, customer.OrganizationID, gameID) + if errors.Is(err, repository.ErrGameRewardConfigNotFound) { + return gameSessionRejected("the game has no active reward configuration") + } + if err != nil { + return err + } + // A reward must name the budget paying for it (D6), so no game starts while + // none is set for the period. + now := p.now() + if _, err := p.budgets.GetGlobalBudgetOn(ctx, customer.OrganizationID, walletDay(now)); err != nil { + if errors.Is(err, repository.ErrGameBudgetNotFound) { + return gameSessionRejected("no EnakGame budget is set for this period") + } + return err + } + + sessionID := uuid.New() + spend, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ + CustomerID: customerID, + Currency: constants.WalletCurrencyCoin, + Type: constants.WalletTxTypeGameSpend, + Amount: game.EntryCost, + ReferenceType: constants.WalletRefTypeGameSession, + ReferenceID: sessionID, + Description: truncateDescription("Main " + game.Name), + Metadata: entities.Metadata{"game_id": game.ID.String(), "entry_cost": game.EntryCost}, + IdempotencyKey: walletKey, + }}) + if errors.Is(err, repository.ErrWalletInsufficientBalance) { + return gameSessionRejected("not enough EnakCoin") + } + if err != nil { + return err + } + session = &entities.GameSession{ + ID: sessionID, + OrganizationID: customer.OrganizationID, + CustomerID: customerID, + GameID: game.ID, + RewardConfigID: config.ID, + EntryCost: game.EntryCost, + Status: constants.GameSessionStatusStarted, + StartedAt: now, + ExpiresAt: now.Add(time.Duration(game.SessionTTLSeconds) * time.Second), + SpendTransactionID: spend.Transaction.ID, + } + return p.sessions.CreateSession(ctx, session) + }) + if err != nil { + return nil, err + } + + balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now()) + if err != nil { + return nil, err + } + return &models.GameSessionStart{ + SessionID: session.ID, + GameID: session.GameID, + EntryCost: session.EntryCost, + ExpiresAt: session.ExpiresAt, + CoinBalance: balances[constants.WalletCurrencyCoin], + Replayed: replayed, + }, nil +} + +// RefundSession gives back the entry cost of a STARTED session, in a transaction of +// its own (§7.3, PRD §10.2). It reports false when the session had already left +// STARTED, which changes nothing. +func (p *GameSessionProcessor) RefundSession(ctx context.Context, organizationID, sessionID uuid.UUID, reason, source string) (bool, error) { + refunded := false + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + session, err := p.sessions.GetSession(ctx, organizationID, sessionID) + if err != nil { + return err + } + if err := p.wallet.LockWallet(ctx, session.CustomerID); err != nil { + return err + } + // Read again under the lock: another flow may have finished it meanwhile. + if session, err = p.sessions.GetSession(ctx, organizationID, sessionID); err != nil { + return err + } + refunded, err = p.refundLocked(ctx, session, reason, source) + return err + }) + if errors.Is(err, errGameSessionMoved) { + return false, nil + } + return refunded, err +} + +// refundLocked refunds a session inside the caller's transaction, with the +// customer's wallet already locked. The EnakCoin go back to lots that keep the expiry +// of those the entry cost was taken from, but last at least seven days (§6.3). +// +// When the session leaves STARTED between the read and the update, it returns +// errGameSessionMoved so the caller rolls the credit back. +func (p *GameSessionProcessor) refundLocked(ctx context.Context, session *entities.GameSession, reason, source string) (bool, error) { + if session.Status != constants.GameSessionStatusStarted { + return false, nil + } + lots, err := p.wallet.RefundLots(ctx, session.SpendTransactionID) + if err != nil { + return false, err + } + spendID := session.SpendTransactionID + refund, err := p.wallet.Credit(ctx, WalletCreditInput{ + WalletEntry: WalletEntry{ + CustomerID: session.CustomerID, + Currency: constants.WalletCurrencyCoin, + Type: constants.WalletTxTypeGameSpendRefund, + Amount: session.EntryCost, + ReferenceType: constants.WalletRefTypeGameSession, + ReferenceID: session.ID, + ReversesTransactionID: &spendID, + Description: "Pengembalian biaya main game", + Metadata: entities.Metadata{"game_id": session.GameID.String(), "refund_reason": reason}, + IdempotencyKey: "game-refund:" + session.ID.String(), + }, + Lots: lots, + }) + if err != nil { + return false, err + } + now := p.now() + moved, err := p.sessions.RefundSession(ctx, session.ID, refund.Transaction.ID, reason, now) + if err != nil { + return false, err + } + if !moved { + return false, errGameSessionMoved + } + err = p.audit.Record(ctx, AuditEntry{ + OrganizationID: session.OrganizationID, + ActorType: constants.AuditActorSystem, + EntityType: constants.AuditEntityGameSession, + EntityID: session.ID, + Action: "REFUNDED", + Before: map[string]any{"status": constants.GameSessionStatusStarted}, + After: map[string]any{ + "status": constants.GameSessionStatusRefunded, "refund_reason": reason, + "refund_transaction_id": refund.Transaction.ID, "amount": session.EntryCost, + }, + Reason: &reason, + Source: source, + }) + if err != nil { + return false, err + } + session.Status, session.RefundReason, session.RefundTransactionID, session.EndedAt = constants.GameSessionStatusRefunded, &reason, &refund.Transaction.ID, &now + return true, nil +} + +// ProcessDueSessions is the session job (§7.3): a STARTED session whose game is no +// longer ACTIVE is refunded at once; an expired one is refunded when completing it +// failed on a system error, and otherwise only expired, keeping the entry cost. +// Each session is handled in its own transaction. It returns how many it refunded and +// expired. +func (p *GameSessionProcessor) ProcessDueSessions(ctx context.Context) (refunded, expired int, err error) { + var failures []error + for batch := 0; batch < gameSessionJobBatches; batch++ { + due, err := p.sessions.ListDueSessions(ctx, p.now(), gameSessionJobBatch) + if err != nil { + return refunded, expired, err + } + changed := 0 + for _, d := range due { + action, err := p.processDue(ctx, d) + switch { + case err != nil: + failures = append(failures, fmt.Errorf("session %s: %w", d.ID, err)) + logger.NonContext.Error("Game session job failed on a session", err) + case action == constants.GameSessionStatusRefunded: + refunded++ + changed++ + case action == constants.GameSessionStatusExpired: + expired++ + changed++ + } + } + // A short batch was the last; a batch where nothing changed would only be + // read again. + if len(due) < gameSessionJobBatch || changed == 0 { + break + } + } + return refunded, expired, errors.Join(failures...) +} + +// processDue handles one session and returns the status it moved it to, or "" when it +// left it alone. +func (p *GameSessionProcessor) processDue(ctx context.Context, d repository.DueGameSession) (string, error) { + action := "" + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if err := p.wallet.LockWallet(ctx, d.CustomerID); err != nil { + return err + } + session, err := p.sessions.GetSession(ctx, d.OrganizationID, d.ID) + if err != nil { + return err + } + if session.Status != constants.GameSessionStatusStarted { + return nil + } + game, err := p.games.GetGame(ctx, session.OrganizationID, session.GameID) + if err != nil { + return err + } + now := p.now() + reason := "" + switch { + case game.Status != constants.GameStatusActive: + reason = constants.GameSessionRefundGameDeactivated + case now.Before(session.ExpiresAt): + return nil + case session.CompletionFailedAt != nil: + reason = constants.GameSessionRefundSystemError + default: + // Left behind by the customer: no refund (PRD §10.2). + moved, err := p.sessions.ExpireSession(ctx, session.ID, now) + if moved { + action = constants.GameSessionStatusExpired + } + return err + } + refunded, err := p.refundLocked(ctx, session, reason, constants.AuditSourceSessionJob) + if refunded { + action = constants.GameSessionStatusRefunded + } + return err + }) + if errors.Is(err, errGameSessionMoved) { + return "", nil + } + return action, err +} + +// ListGames returns the ACTIVE games of the customer's organization, each with the +// events making it pay more right now. +func (p *GameSessionProcessor) ListGames(ctx context.Context, customerID uuid.UUID) ([]models.CustomerEnakGame, error) { + customer, err := p.customers.GetCustomer(ctx, customerID) + if err != nil { + return nil, err + } + games, _, err := p.games.ListGames(ctx, repository.EnakGameFilter{ + OrganizationID: customer.OrganizationID, Statuses: []string{constants.GameStatusActive}, Limit: customerGameListLimit, + }) + if err != nil { + return nil, err + } + events, err := p.events.ActiveEventsByGame(ctx, customer.OrganizationID, p.now()) + if err != nil { + return nil, err + } + out := make([]models.CustomerEnakGame, 0, len(games)) + for _, g := range games { + item := models.CustomerEnakGame{ + ID: g.ID, Name: g.Name, Description: g.Description, ThumbnailURL: g.ThumbnailURL, GameURL: g.GameURL, + Version: g.Version, EntryCost: g.EntryCost, SessionTTLSeconds: g.SessionTTLSeconds, Events: []models.CustomerGameEvent{}, + } + for _, e := range events[g.ID] { + item.Events = append(item.Events, models.CustomerGameEvent{ + ID: e.ID, Name: e.Name, BannerURL: e.BannerURL, Multiplier: e.Multiplier, Bonus: e.Bonus, EndAt: e.EndAt, + }) + } + if g.Slug != nil { + item.Slug = *g.Slug + } + out = append(out, item) + } + return out, nil +} + +// ListSessions returns a page of the customer's sessions, newest first. +func (p *GameSessionProcessor) ListSessions(ctx context.Context, customerID uuid.UUID, page, limit int) (*models.PaginatedResponse[models.CustomerGameSession], error) { + page, limit = enakGamePage(page, limit) + sessions, total, err := p.sessions.ListCustomerSessions(ctx, customerID, (page-1)*limit, limit) + if err != nil { + return nil, err + } + items := make([]models.CustomerGameSession, 0, len(sessions)) + for i := range sessions { + items = append(items, customerGameSessionModel(&sessions[i])) + } + return &models.PaginatedResponse[models.CustomerGameSession]{Data: items, Pagination: enakGamePagination(page, limit, total)}, nil +} + +// GetSession returns one of the customer's sessions; another customer's is not found. +func (p *GameSessionProcessor) GetSession(ctx context.Context, customerID, sessionID uuid.UUID) (*models.CustomerGameSession, error) { + session, err := p.sessions.GetCustomerSession(ctx, customerID, sessionID) + if err != nil { + return nil, err + } + m := customerGameSessionModel(session) + return &m, nil +} + +func customerGameSessionModel(s *entities.GameSession) models.CustomerGameSession { + return models.CustomerGameSession{ + ID: s.ID, GameID: s.GameID, Status: s.Status, EntryCost: s.EntryCost, RewardTotal: s.RewardTotal, + StartedAt: s.StartedAt, ExpiresAt: s.ExpiresAt, EndedAt: s.EndedAt, RefundReason: s.RefundReason, + } +} + +// truncateDescription keeps a ledger description within its 255 characters. +func truncateDescription(s string) string { + runes := []rune(s) + if len(runes) <= 255 { + return s + } + return string(runes[:255]) +} diff --git a/internal/processor/game_session_reward.go b/internal/processor/game_session_reward.go new file mode 100644 index 0000000..4f081a4 --- /dev/null +++ b/internal/processor/game_session_reward.go @@ -0,0 +1,222 @@ +package processor + +import ( + "context" + "math" + "sort" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +// The kinds of reward component (D6). +const ( + // The game's own reward, paid by the global budget. + gameRewardKindBase = "BASE" + // What one event adds, paid by the event's budget. + gameRewardKindEvent = "EVENT" +) + +// The event limits (PRD §35), by the name the breakdown and the response give them. +const ( + gameLimitEvent = "EVENT_LIMIT" + gameLimitEventUserDaily = "EVENT_USER_DAILY" +) + +// eventLifetimeDay is the day of an event's counter over its whole run (§5.8). +var eventLifetimeDay = time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC) + +// eventUserScope is the counter of one customer in one event: a name derived from +// both, since a counter has one scope id (§5.8). +func eventUserScope(eventID, customerID uuid.UUID) uuid.UUID { + return uuid.NewSHA1(eventID, customerID[:]) +} + +// eventExtra is what an event adds to a base reward: base × (multiplier − 1), rounded +// down to whole EnakCoin, plus the bonus. Each event works on the base, so events do +// not multiply each other (PRD §16, "controlled stacking"). +func eventExtra(base int64, e *entities.GameEvent) int64 { + var extra int64 + if e.Multiplier != nil { + hundredths := int64(math.Round(*e.Multiplier * 100)) + extra = base * (hundredths - 100) / 100 + } + if e.Bonus != nil { + extra += *e.Bonus + } + return extra +} + +// rewardComponents prices a believed base reward into its parts: the base, capped by +// max_reward, then what each active event adds, highest priority first. The cap holds +// for the total: what passes it is cut from the lowest priority event first. An event +// adds nothing to a play that earned nothing. +func rewardComponents(base, maxReward int64, events []entities.GameEvent) ([]gameRewardComponent, bool) { + capped := false + if maxReward > 0 && base > maxReward { + base, capped = maxReward, true + } + if base <= 0 { + return nil, capped + } + parts := []gameRewardComponent{{Kind: gameRewardKindBase, Earned: base, Amount: base}} + total := base + for i := range events { + e := &events[i] + extra := eventExtra(base, e) + if extra <= 0 { + continue + } + id := e.ID + parts = append(parts, gameRewardComponent{ + Kind: gameRewardKindEvent, BudgetID: e.BudgetID, EventID: &id, Multiplier: e.Multiplier, Bonus: e.Bonus, + Earned: extra, Amount: extra, + }) + total += extra + } + if maxReward > 0 && total > maxReward { + capped = true + trimFromEnd(parts, total-maxReward, nil) + } + return parts, capped +} + +// trimFromEnd takes cut off the parts, lowest priority event first and the base last. +// release, when given, is told what came off each part. +func trimFromEnd(parts []gameRewardComponent, cut int64, release func(*gameRewardComponent, int64) error) error { + for i := len(parts) - 1; i >= 0 && cut > 0; i-- { + d := parts[i].Amount + if d > cut { + d = cut + } + parts[i].Amount -= d + cut -= d + if release != nil && d > 0 { + if err := release(&parts[i], d); err != nil { + return err + } + } + } + return nil +} + +// guardReward runs the parts of a reward through every limit (§9, PRD §35): first +// each event's own limits on what it adds, then the daily limits on the total, which +// cut events before the base. It returns the limits that cut. +// +// Counters are taken in one order for every completion (events by id, then user, +// game, organization), so no two completions wait on each other the other way round. +func (p *GameSessionProcessor) guardReward(ctx context.Context, session *entities.GameSession, game *entities.Game, events []entities.GameEvent, + settings *models.OrganizationLoyaltySettings, now time.Time, parts []gameRewardComponent) ([]gameGuardCut, error) { + byID := map[uuid.UUID]*entities.GameEvent{} + for i := range events { + byID[events[i].ID] = &events[i] + } + day := walletDay(now) + order := make([]int, 0, len(parts)) + for i := range parts { + if parts[i].Kind == gameRewardKindEvent { + order = append(order, i) + } + } + sort.Slice(order, func(a, b int) bool { return parts[order[a]].EventID.String() < parts[order[b]].EventID.String() }) + + var cuts []gameGuardCut + for _, i := range order { + part := &parts[i] + e := byID[*part.EventID] + asked := part.Amount + lifetime, err := p.counters.Consume(ctx, session.OrganizationID, constants.GameRewardScopeEvent, e.ID, eventLifetimeDay, asked, optionalLimit(e.RewardLimit)) + if err != nil { + return nil, err + } + if lifetime < asked { + cuts = append(cuts, gameGuardCut{Limit: gameLimitEvent, EventID: part.EventID, Value: optionalLimit(e.RewardLimit), Asked: asked, Allowed: lifetime}) + part.LimitedBy = append(part.LimitedBy, gameLimitEvent) + } + perUser, err := p.counters.Consume(ctx, session.OrganizationID, constants.GameRewardScopeEvent, eventUserScope(e.ID, session.CustomerID), day, lifetime, optionalLimit(e.UserDailyLimit)) + if err != nil { + return nil, err + } + if perUser < lifetime { + cuts = append(cuts, gameGuardCut{Limit: gameLimitEventUserDaily, EventID: part.EventID, Value: optionalLimit(e.UserDailyLimit), Asked: lifetime, Allowed: perUser}) + part.LimitedBy = append(part.LimitedBy, gameLimitEventUserDaily) + if err := p.counters.Release(ctx, session.OrganizationID, constants.GameRewardScopeEvent, e.ID, eventLifetimeDay, lifetime-perUser); err != nil { + return nil, err + } + } + part.Amount = perUser + } + + total := sumComponents(parts) + if total == 0 { + return cuts, nil + } + allowed, daily, err := p.applyGuard(ctx, session, game, settings, now, total) + if err != nil { + return nil, err + } + cuts = append(cuts, daily...) + // The event counters took what the daily limits then cut: give it back. + err = trimFromEnd(parts, total-allowed, func(part *gameRewardComponent, d int64) error { + if part.Kind != gameRewardKindEvent { + return nil + } + if err := p.counters.Release(ctx, session.OrganizationID, constants.GameRewardScopeEvent, *part.EventID, eventLifetimeDay, d); err != nil { + return err + } + return p.counters.Release(ctx, session.OrganizationID, constants.GameRewardScopeEvent, eventUserScope(*part.EventID, session.CustomerID), day, d) + }) + return cuts, err +} + +func optionalLimit(limit *int64) int64 { + if limit == nil { + return 0 + } + return *limit +} + +func sumComponents(parts []gameRewardComponent) int64 { + var total int64 + for _, part := range parts { + total += part.Amount + } + return total +} + +// rewardParts sums a stored breakdown's components into what the customer sees. +func rewardParts(b gameRewardBreakdown) models.GameSessionRewardParts { + var out models.GameSessionRewardParts + for _, c := range b.Components { + if c.Kind == gameRewardKindEvent { + out.Event += c.Amount + } else { + out.Base += c.Amount + } + } + return out +} + +// paymentsByBudget merges the components paid by the same budget, in order, since a +// budget pays one ledger row per session (D6). +func paymentsByBudget(parts []gameRewardComponent) []gameRewardComponent { + var out []gameRewardComponent + index := map[uuid.UUID]int{} + for _, part := range parts { + if part.Amount <= 0 { + continue + } + if i, ok := index[part.BudgetID]; ok { + out[i].Amount += part.Amount + continue + } + index[part.BudgetID] = len(out) + out = append(out, gameRewardComponent{BudgetID: part.BudgetID, Kind: part.Kind, Amount: part.Amount}) + } + return out +} diff --git a/internal/processor/game_session_reward_test.go b/internal/processor/game_session_reward_test.go new file mode 100644 index 0000000..e78bdb9 --- /dev/null +++ b/internal/processor/game_session_reward_test.go @@ -0,0 +1,65 @@ +package processor + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "apskel-pos-be/internal/entities" +) + +func gameEvent(multiplier *float64, bonus *int64) entities.GameEvent { + return entities.GameEvent{ID: uuid.New(), BudgetID: uuid.New(), Multiplier: multiplier, Bonus: bonus} +} + +func amounts(parts []gameRewardComponent) []int64 { + out := make([]int64, 0, len(parts)) + for _, p := range parts { + out = append(out, p.Amount) + } + return out +} + +// PRD §7, §14, §16 with the default stacking rules. +func TestRewardComponents(t *testing.T) { + ramadan := gameEvent(ptr(2.0), nil) + parts, capped := rewardComponents(10, 0, []entities.GameEvent{ramadan}) + assert.False(t, capped) + assert.Equal(t, []int64{10, 10}, amounts(parts), "10 Coin + Ramadan 2x: 10 from the global budget, 10 from the event's") + assert.Equal(t, gameRewardKindBase, parts[0].Kind) + assert.Equal(t, gameRewardKindEvent, parts[1].Kind) + assert.Equal(t, ramadan.BudgetID, parts[1].BudgetID) + assert.Equal(t, ramadan.ID, *parts[1].EventID) + + // Each event works on the base: 2x and 1.5x add 10 and 5, they do not compound. + // Bonuses are counted on their own. + parts, _ = rewardComponents(10, 0, []entities.GameEvent{gameEvent(ptr(2.0), nil), gameEvent(ptr(1.5), ptr(int64(3))), gameEvent(nil, ptr(int64(5)))}) + assert.Equal(t, []int64{10, 10, 8, 5}, amounts(parts)) + + // Rounded down to whole EnakCoin. + parts, _ = rewardComponents(3, 0, []entities.GameEvent{gameEvent(ptr(1.5), nil), gameEvent(ptr(1.33), nil)}) + assert.Equal(t, []int64{3, 1}, amounts(parts), "1.5 adds 1.5 → 1; 1.33 adds 0.99 → 0 and is left out") + + // The cap holds for the total, cutting the lowest priority event first. + parts, capped = rewardComponents(10, 22, []entities.GameEvent{gameEvent(ptr(2.0), nil), gameEvent(nil, ptr(int64(5)))}) + assert.True(t, capped) + assert.Equal(t, []int64{10, 10, 2}, amounts(parts)) + assert.Equal(t, []int64{10, 10, 5}, []int64{parts[0].Earned, parts[1].Earned, parts[2].Earned}) + parts, _ = rewardComponents(30, 20, []entities.GameEvent{gameEvent(ptr(2.0), nil)}) + assert.Equal(t, []int64{20, 0}, amounts(parts), "a base at the cap leaves events nothing") + + // Nothing earned, nothing added: a bonus needs a play that earned. + parts, _ = rewardComponents(0, 0, []entities.GameEvent{gameEvent(nil, ptr(int64(5)))}) + assert.Empty(t, parts) + + // Events paid by the same budget pay one ledger row. + shared := uuid.New() + a, b := gameEvent(nil, ptr(int64(2))), gameEvent(nil, ptr(int64(3))) + a.BudgetID, b.BudgetID = shared, shared + parts, _ = rewardComponents(10, 0, []entities.GameEvent{a, b}) + parts[0].BudgetID = uuid.New() + payments := paymentsByBudget(parts) + assert.Len(t, payments, 2) + assert.EqualValues(t, 5, payments[1].Amount) +} diff --git a/internal/processor/loyalty_settings_processor.go b/internal/processor/loyalty_settings_processor.go index 0acf248..740e4c8 100644 --- a/internal/processor/loyalty_settings_processor.go +++ b/internal/processor/loyalty_settings_processor.go @@ -260,7 +260,8 @@ func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField { } } -// organizationLoyaltyFields describes every organization key (F2, F12), bound to s. +// organizationLoyaltyFields describes every organization key (F2, F12, and the EnakGame +// limits of docs/rfc-enakgame.md §5.10), bound to s. func organizationLoyaltyFields(s *models.OrganizationLoyaltySettings) []loyaltyField { fields := []loyaltyField{ intLoyaltyField(constants.LoyaltyPointValueKey, &s.PointValue, constants.LoyaltyPointValueDefault, 1, noLoyaltyMax), @@ -270,6 +271,8 @@ func organizationLoyaltyFields(s *models.OrganizationLoyaltySettings) []loyaltyF intLoyaltyField(constants.LoyaltyTransferMinAmountKey, &s.Transfer.MinAmount, constants.LoyaltyTransferMinAmountDefault, 1, noLoyaltyMax), optionalIntLoyaltyField(constants.LoyaltyTransferMaxPerTransactionKey, &s.Transfer.MaxPerTransaction, 1), optionalIntLoyaltyField(constants.LoyaltyTransferDailyLimitKey, &s.Transfer.DailyLimit, 1), + intLoyaltyField(constants.EnakGameLimitUserDailyKey, &s.EnakGame.UserDailyLimit, 0, 0, noLoyaltyMax), + intLoyaltyField(constants.EnakGameLimitGlobalDailyKey, &s.EnakGame.GlobalDailyLimit, 0, 0, noLoyaltyMax), } fields = append(fields, expiryLoyaltyFields("loyalty.point.", &s.PointExpiry)...) return append(fields, expiryLoyaltyFields("loyalty.coin.", &s.CoinExpiry)...) diff --git a/internal/processor/loyalty_settings_processor_test.go b/internal/processor/loyalty_settings_processor_test.go index 3a193ba..7053f2d 100644 --- a/internal/processor/loyalty_settings_processor_test.go +++ b/internal/processor/loyalty_settings_processor_test.go @@ -123,6 +123,7 @@ func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing. Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1}, PointExpiry: expiry, CoinExpiry: expiry, + EnakGame: models.EnakGameLimitSettings{UserDailyLimit: 0, GlobalDailyLimit: 0}, }, *s) } @@ -261,6 +262,7 @@ func TestLoyaltySettings_UpdateOrganization(t *testing.T) { settings.PointValue = 100 settings.CoinExpiry.Enabled = true settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay + settings.EnakGame.UserDailyLimit = 50 changes, _, err := p.UpdateOrganization(ctx, org, admin, *settings) require.NoError(t, err) @@ -269,11 +271,14 @@ func TestLoyaltySettings_UpdateOrganization(t *testing.T) { keys = append(keys, c.Key) assert.Nil(t, c.OutletID, "organization settings have no outlet") } - assert.ElementsMatch(t, []string{constants.LoyaltyPointValueKey, "loyalty.coin.expiry_enabled", "loyalty.coin.expiry_unit"}, keys) + assert.ElementsMatch(t, []string{constants.LoyaltyPointValueKey, "loyalty.coin.expiry_enabled", "loyalty.coin.expiry_unit", "enakgame.limit.user_daily"}, keys) value, err := p.PointValue(ctx, org) require.NoError(t, err) assert.Equal(t, int64(100), value) + saved, err := p.Organization(ctx, org) + require.NoError(t, err) + assert.Equal(t, models.EnakGameLimitSettings{UserDailyLimit: 50, GlobalDailyLimit: 0}, saved.EnakGame) } func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) { @@ -312,6 +317,8 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) { "fixed date 13-01": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.FixedDates = []string{"13-01"} }, "fixed date 02-29": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.FixedDates = []string{"02-29"} }, "negative grace": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.GraceMonths = -1 }, + "negative user daily": func(s *models.OrganizationLoyaltySettings) { s.EnakGame.UserDailyLimit = -1 }, + "negative global daily": func(s *models.OrganizationLoyaltySettings) { s.EnakGame.GlobalDailyLimit = -1 }, } { s, err := p.Organization(ctx, org) require.NoError(t, err) diff --git a/internal/processor/result_validator.go b/internal/processor/result_validator.go new file mode 100644 index 0000000..1dbf5ed --- /dev/null +++ b/internal/processor/result_validator.go @@ -0,0 +1,41 @@ +package processor + +import ( + "fmt" + "time" + + "apskel-pos-be/internal/entities" +) + +// ValidateResult checks a result against a game's result_rules (docs/rfc-enakgame.md +// §7.2 step 5) and returns why it is not believable, or nil when it is. A rule left +// out does not apply, so a game without result_rules accepts every result. +// +// A failed result is not an error: the session completes, earns nothing and is +// flagged, with these reasons in its reward_breakdown. +func ValidateResult(rules entities.GameResultRules, result SessionResult, startedAt, submittedAt time.Time) []string { + var reasons []string + elapsed := submittedAt.Sub(startedAt).Seconds() + if rules.MinDurationSeconds != nil && elapsed < float64(*rules.MinDurationSeconds) { + reasons = append(reasons, fmt.Sprintf("played %.0f seconds, under the minimum of %d", elapsed, *rules.MinDurationSeconds)) + } + if result.Score != nil { + score := *result.Score + if score < 0 { + reasons = append(reasons, "score cannot be negative") + } + if rules.MaxScore != nil && score > *rules.MaxScore { + reasons = append(reasons, fmt.Sprintf("score %d is above the maximum of %d", score, *rules.MaxScore)) + } + if rules.MaxScorePerSecond != nil && score > 0 { + // A score with no time played at all is never believable. + if elapsed <= 0 || float64(score)/elapsed > *rules.MaxScorePerSecond { + reasons = append(reasons, fmt.Sprintf("score %d in %.0f seconds is above %g per second", score, elapsed, *rules.MaxScorePerSecond)) + } + } + } + if result.Outcome != nil && len(rules.Outcomes) > 0 && !containsString(rules.Outcomes, *result.Outcome) { + reasons = append(reasons, fmt.Sprintf("unknown outcome %q", *result.Outcome)) + } + return reasons +} diff --git a/internal/processor/result_validator_test.go b/internal/processor/result_validator_test.go new file mode 100644 index 0000000..c756cf8 --- /dev/null +++ b/internal/processor/result_validator_test.go @@ -0,0 +1,46 @@ +package processor + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "apskel-pos-be/internal/entities" +) + +func TestValidateResult(t *testing.T) { + start := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) + after := func(seconds int) time.Time { return start.Add(time.Duration(seconds) * time.Second) } + score := func(v int64) *int64 { return &v } + outcome := func(v string) *string { return &v } + rules := entities.GameResultRules{ + MaxScore: score(1000), + MinDurationSeconds: score(10), + MaxScorePerSecond: ptr(20.0), + Outcomes: []string{"WIN", "LOSE"}, + } + + for name, tc := range map[string]struct { + rules entities.GameResultRules + result SessionResult + elapsed int + reasons int + }{ + "no rules accept anything": {entities.GameResultRules{}, SessionResult{Score: score(999999), Outcome: outcome("ANY")}, 0, 0}, + "within every rule": {rules, SessionResult{Score: score(1000), Outcome: outcome("WIN")}, 60, 0}, + "no score, no outcome": {rules, SessionResult{}, 10, 0}, + "too short": {rules, SessionResult{}, 9, 1}, + "score above max": {rules, SessionResult{Score: score(1001)}, 600, 1}, + "too fast": {rules, SessionResult{Score: score(201)}, 10, 1}, + "exactly the per-second cap": {rules, SessionResult{Score: score(200)}, 10, 0}, + "negative score": {rules, SessionResult{Score: score(-1)}, 60, 1}, + "unknown outcome": {rules, SessionResult{Outcome: outcome("DRAW")}, 60, 1}, + "zero time, some score": {entities.GameResultRules{MaxScorePerSecond: ptr(5.0)}, SessionResult{Score: score(1)}, 0, 1}, + "zero time, zero score": {entities.GameResultRules{MaxScorePerSecond: ptr(5.0)}, SessionResult{Score: score(0)}, 0, 0}, + "every rule broken": {rules, SessionResult{Score: score(5000), Outcome: outcome("DRAW")}, 1, 4}, + } { + reasons := ValidateResult(tc.rules, tc.result, start, after(tc.elapsed)) + assert.Len(t, reasons, tc.reasons, "%s: %v", name, reasons) + } +} diff --git a/internal/processor/reward_calculator.go b/internal/processor/reward_calculator.go new file mode 100644 index 0000000..10922e9 --- /dev/null +++ b/internal/processor/reward_calculator.go @@ -0,0 +1,320 @@ +package processor + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "strings" + + "apskel-pos-be/internal/constants" +) + +var ( + // ErrInvalidRewardRules means a reward configuration's rules do not fit its type + // (docs/rfc-enakgame.md §8). Checked when the configuration is created. + ErrInvalidRewardRules = errors.New("invalid reward rules") + // ErrRewardResultUnusable means a result lacks what the rules need to price it: no + // score for SCORE_BASED, an outcome the rules do not list, a score outside every + // band. The session earns nothing and is flagged. + ErrRewardResultUnusable = errors.New("result cannot be rewarded") +) + +// rewardWeightLimit bounds the total weight of a PROBABILITY table, far below where +// the sum could overflow. +const rewardWeightLimit = int64(1_000_000_000_000) + +// SessionResult is what the client reports about a play: data only, never a reward +// amount (P1). +type SessionResult struct { + Score *int64 `json:"score,omitempty"` + Outcome *string `json:"outcome,omitempty"` +} + +// RewardRNG draws the random number of a PROBABILITY reward. +type RewardRNG interface { + // Int63n returns a uniform number in [0, n). + Int63n(n int64) (int64, error) +} + +// CryptoRewardRNG draws from crypto/rand, so the outcome cannot be predicted from the +// time the way a reseeded math/rand can (§8). +type CryptoRewardRNG struct{} + +func (CryptoRewardRNG) Int63n(n int64) (int64, error) { + v, err := rand.Int(rand.Reader, big.NewInt(n)) + if err != nil { + return 0, err + } + return v.Int64(), nil +} + +// RewardCalculator computes the base reward of one reward type (§8). Base rewards are +// whole EnakCoin; event modifiers and the max_reward cap come after. +type RewardCalculator interface { + // Validate checks rules when a configuration is created. + Validate(rules json.RawMessage) error + // Calculate prices a result under rules that passed Validate. detail says how the + // amount was reached, for reward_breakdown. + Calculate(rules json.RawMessage, result SessionResult, rng RewardRNG) (base int64, detail map[string]any, err error) +} + +var rewardCalculators = map[string]RewardCalculator{ + constants.GameRewardTypeFixed: fixedReward{}, + constants.GameRewardTypeScoreBased: scoreBasedReward{}, + constants.GameRewardTypeOutcomeBased: outcomeBasedReward{}, + constants.GameRewardTypeProbability: probabilityReward{}, +} + +// RewardCalculatorFor returns the calculator of a reward type. +func RewardCalculatorFor(rewardType string) (RewardCalculator, error) { + c, ok := rewardCalculators[rewardType] + if !ok { + return nil, fmt.Errorf("%w: unknown reward type %q", ErrInvalidRewardRules, rewardType) + } + return c, nil +} + +func invalidRules(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalidRewardRules, fmt.Sprintf(format, args...)) +} + +func unusableResult(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrRewardResultUnusable, fmt.Sprintf(format, args...)) +} + +// decodeRules reads rules strictly: an unknown field is a typo, not something to +// ignore. +func decodeRules(rules json.RawMessage, v any) error { + dec := json.NewDecoder(bytes.NewReader(rules)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return invalidRules("%v", err) + } + if _, err := dec.Token(); err != io.EOF { + return invalidRules("unexpected data after the rules") + } + return nil +} + +// FIXED: {"amount": 5}. +type fixedReward struct{} + +type fixedRules struct { + Amount *int64 `json:"amount"` +} + +func (fixedReward) parse(rules json.RawMessage) (fixedRules, error) { + var r fixedRules + if err := decodeRules(rules, &r); err != nil { + return r, err + } + if r.Amount == nil || *r.Amount < 0 { + return r, invalidRules("amount must be 0 or more") + } + return r, nil +} + +func (f fixedReward) Validate(rules json.RawMessage) error { + _, err := f.parse(rules) + return err +} + +func (f fixedReward) Calculate(rules json.RawMessage, _ SessionResult, _ RewardRNG) (int64, map[string]any, error) { + r, err := f.parse(rules) + if err != nil { + return 0, nil, err + } + return *r.Amount, map[string]any{"reward_type": constants.GameRewardTypeFixed}, nil +} + +// SCORE_BASED: {"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}. +// Bands run in order from 0, each starting right after the previous one ends; only the +// last may leave max out, to cover every higher score. +type scoreBasedReward struct{} + +type scoreBand struct { + Min *int64 `json:"min"` + Max *int64 `json:"max"` + Amount *int64 `json:"amount"` +} + +type scoreRules struct { + Bands []scoreBand `json:"bands"` +} + +func (scoreBasedReward) parse(rules json.RawMessage) (scoreRules, error) { + var r scoreRules + if err := decodeRules(rules, &r); err != nil { + return r, err + } + if len(r.Bands) == 0 { + return r, invalidRules("at least one band is required") + } + for i, b := range r.Bands { + if b.Min == nil || b.Amount == nil { + return r, invalidRules("band %d needs min and amount", i+1) + } + if *b.Amount < 0 { + return r, invalidRules("band %d amount must be 0 or more", i+1) + } + if b.Max != nil && *b.Max < *b.Min { + return r, invalidRules("band %d max is below its min", i+1) + } + if i == 0 { + if *b.Min != 0 { + return r, invalidRules("the first band must start at 0") + } + continue + } + prev := r.Bands[i-1] + if prev.Max == nil { + return r, invalidRules("only the last band may leave max out") + } + switch { + case *b.Min <= *prev.Max: + return r, invalidRules("band %d overlaps band %d", i+1, i) + case *b.Min > *prev.Max+1: + return r, invalidRules("scores %d to %d are in no band", *prev.Max+1, *b.Min-1) + } + } + return r, nil +} + +func (s scoreBasedReward) Validate(rules json.RawMessage) error { + _, err := s.parse(rules) + return err +} + +func (s scoreBasedReward) Calculate(rules json.RawMessage, result SessionResult, _ RewardRNG) (int64, map[string]any, error) { + r, err := s.parse(rules) + if err != nil { + return 0, nil, err + } + if result.Score == nil { + return 0, nil, unusableResult("a score is required") + } + score := *result.Score + for i, b := range r.Bands { + if score >= *b.Min && (b.Max == nil || score <= *b.Max) { + return *b.Amount, map[string]any{"reward_type": constants.GameRewardTypeScoreBased, "score": score, "band": i + 1}, nil + } + } + return 0, nil, unusableResult("score %d is in no band", score) +} + +// OUTCOME_BASED: {"outcomes": {"PERFECT": 20, "GOOD": 10, "FAIL": 0}}. +type outcomeBasedReward struct{} + +type outcomeRules struct { + Outcomes map[string]int64 `json:"outcomes"` +} + +func (outcomeBasedReward) parse(rules json.RawMessage) (outcomeRules, error) { + var r outcomeRules + if err := decodeRules(rules, &r); err != nil { + return r, err + } + if len(r.Outcomes) == 0 { + return r, invalidRules("at least one outcome is required") + } + for outcome, amount := range r.Outcomes { + if strings.TrimSpace(outcome) == "" { + return r, invalidRules("an outcome needs a name") + } + if amount < 0 { + return r, invalidRules("outcome %s amount must be 0 or more", outcome) + } + } + return r, nil +} + +func (o outcomeBasedReward) Validate(rules json.RawMessage) error { + _, err := o.parse(rules) + return err +} + +func (o outcomeBasedReward) Calculate(rules json.RawMessage, result SessionResult, _ RewardRNG) (int64, map[string]any, error) { + r, err := o.parse(rules) + if err != nil { + return 0, nil, err + } + if result.Outcome == nil { + return 0, nil, unusableResult("an outcome is required") + } + amount, ok := r.Outcomes[*result.Outcome] + if !ok { + return 0, nil, unusableResult("unknown outcome %q", *result.Outcome) + } + return amount, map[string]any{"reward_type": constants.GameRewardTypeOutcomeBased, "outcome": *result.Outcome}, nil +} + +// PROBABILITY: {"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}. +// Weights are whole numbers, so no check depends on floating point (§8). The draw is +// kept in the detail for audit. +type probabilityReward struct{} + +type probabilityEntry struct { + Weight *int64 `json:"weight"` + Amount *int64 `json:"amount"` +} + +type probabilityRules struct { + Table []probabilityEntry `json:"table"` +} + +func (probabilityReward) parse(rules json.RawMessage) (probabilityRules, int64, error) { + var r probabilityRules + if err := decodeRules(rules, &r); err != nil { + return r, 0, err + } + if len(r.Table) == 0 { + return r, 0, invalidRules("at least one entry is required") + } + var total int64 + for i, e := range r.Table { + if e.Weight == nil || e.Amount == nil { + return r, 0, invalidRules("entry %d needs weight and amount", i+1) + } + if *e.Weight < 1 { + return r, 0, invalidRules("entry %d weight must be at least 1", i+1) + } + if *e.Amount < 0 { + return r, 0, invalidRules("entry %d amount must be 0 or more", i+1) + } + total += *e.Weight + if total > rewardWeightLimit { + return r, 0, invalidRules("the weights add up to more than %d", rewardWeightLimit) + } + } + return r, total, nil +} + +func (p probabilityReward) Validate(rules json.RawMessage) error { + _, _, err := p.parse(rules) + return err +} + +func (p probabilityReward) Calculate(rules json.RawMessage, _ SessionResult, rng RewardRNG) (int64, map[string]any, error) { + r, total, err := p.parse(rules) + if err != nil { + return 0, nil, err + } + roll, err := rng.Int63n(total) + if err != nil { + return 0, nil, fmt.Errorf("failed to draw a reward: %w", err) + } + cumulative := int64(0) + for i, e := range r.Table { + cumulative += *e.Weight + if roll < cumulative { + return *e.Amount, map[string]any{ + "reward_type": constants.GameRewardTypeProbability, "roll": roll, "total_weight": total, "entry": i + 1, + }, nil + } + } + return 0, nil, fmt.Errorf("draw %d is outside the total weight %d", roll, total) +} diff --git a/internal/processor/reward_calculator_test.go b/internal/processor/reward_calculator_test.go new file mode 100644 index 0000000..5f6c12e --- /dev/null +++ b/internal/processor/reward_calculator_test.go @@ -0,0 +1,146 @@ +package processor + +import ( + "encoding/json" + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" +) + +// fixedRNG returns the given draws in turn. +type fixedRNG struct{ draws []int64 } + +func (r *fixedRNG) Int63n(n int64) (int64, error) { + v := r.draws[0] + r.draws = r.draws[1:] + return v % n, nil +} + +func calculate(t *testing.T, rewardType, rules string, result SessionResult, rng RewardRNG) (int64, map[string]any, error) { + t.Helper() + c, err := RewardCalculatorFor(rewardType) + require.NoError(t, err) + require.NoError(t, c.Validate(json.RawMessage(rules)), "the example rules are valid") + return c.Calculate(json.RawMessage(rules), result, rng) +} + +// The examples of docs/enakgame-prd.md §12. +func TestRewardCalculator_PRDExamples(t *testing.T) { + t.Run("FIXED", func(t *testing.T) { + base, _, err := calculate(t, constants.GameRewardTypeFixed, `{"amount": 5}`, SessionResult{}, nil) + require.NoError(t, err) + assert.EqualValues(t, 5, base) + }) + + t.Run("SCORE_BASED", func(t *testing.T) { + rules := `{"bands": [ + {"min": 0, "max": 100, "amount": 1}, + {"min": 101, "max": 500, "amount": 5}, + {"min": 501, "max": 1000, "amount": 10}, + {"min": 1001, "amount": 20}]}` + for score, want := range map[int64]int64{0: 1, 100: 1, 101: 5, 500: 5, 501: 10, 1000: 10, 1001: 20, 99999: 20} { + score := score + base, detail, err := calculate(t, constants.GameRewardTypeScoreBased, rules, SessionResult{Score: &score}, nil) + require.NoError(t, err) + assert.Equal(t, want, base, "score %d", score) + assert.Equal(t, score, detail["score"]) + } + negative := int64(-1) + _, _, err := calculate(t, constants.GameRewardTypeScoreBased, rules, SessionResult{Score: &negative}, nil) + assert.ErrorIs(t, err, ErrRewardResultUnusable) + _, _, err = calculate(t, constants.GameRewardTypeScoreBased, rules, SessionResult{}, nil) + assert.ErrorIs(t, err, ErrRewardResultUnusable, "no score") + capped := `{"bands": [{"min": 0, "max": 10, "amount": 1}]}` + over := int64(11) + _, _, err = calculate(t, constants.GameRewardTypeScoreBased, capped, SessionResult{Score: &over}, nil) + assert.ErrorIs(t, err, ErrRewardResultUnusable, "above the last band's max") + }) + + t.Run("OUTCOME_BASED", func(t *testing.T) { + rules := `{"outcomes": {"PERFECT": 20, "GOOD": 10, "NORMAL": 5, "FAIL": 0}}` + for outcome, want := range map[string]int64{"PERFECT": 20, "GOOD": 10, "NORMAL": 5, "FAIL": 0} { + outcome := outcome + base, detail, err := calculate(t, constants.GameRewardTypeOutcomeBased, rules, SessionResult{Outcome: &outcome}, nil) + require.NoError(t, err) + assert.Equal(t, want, base, outcome) + assert.Equal(t, outcome, detail["outcome"]) + } + unknown := "LEGENDARY" + _, _, err := calculate(t, constants.GameRewardTypeOutcomeBased, rules, SessionResult{Outcome: &unknown}, nil) + assert.ErrorIs(t, err, ErrRewardResultUnusable) + }) + + t.Run("PROBABILITY", func(t *testing.T) { + // 0.1% 1000, 1% 100, 10% 10, 88.9% 0, as whole weights out of 1000. + rules := `{"table": [{"weight": 1, "amount": 1000}, {"weight": 10, "amount": 100}, {"weight": 100, "amount": 10}, {"weight": 889, "amount": 0}]}` + for roll, want := range map[int64]int64{0: 1000, 1: 100, 10: 100, 11: 10, 110: 10, 111: 0, 999: 0} { + base, detail, err := calculate(t, constants.GameRewardTypeProbability, rules, SessionResult{}, &fixedRNG{draws: []int64{roll}}) + require.NoError(t, err) + assert.Equal(t, want, base, "roll %d", roll) + assert.Equal(t, roll, detail["roll"], "the draw is kept for audit") + assert.EqualValues(t, 1000, detail["total_weight"]) + } + }) +} + +func TestRewardCalculator_ProbabilityDistribution(t *testing.T) { + rules := json.RawMessage(`{"table": [{"weight": 1, "amount": 1000}, {"weight": 10, "amount": 100}, {"weight": 100, "amount": 10}, {"weight": 889, "amount": 0}]}`) + c, err := RewardCalculatorFor(constants.GameRewardTypeProbability) + require.NoError(t, err) + + const draws = 100_000 + counts := map[int64]int{} + for i := 0; i < draws; i++ { + base, _, err := c.Calculate(rules, SessionResult{}, CryptoRewardRNG{}) + require.NoError(t, err) + counts[base]++ + } + // Within five standard deviations of the expected share: a correct table fails + // this about once in a few million runs. + for amount, p := range map[int64]float64{1000: 0.001, 100: 0.01, 10: 0.1, 0: 0.889} { + got := float64(counts[amount]) / draws + tolerance := 5 * math.Sqrt(p*(1-p)/draws) + assert.InDelta(t, p, got, tolerance, "amount %d", amount) + } +} + +func TestRewardCalculator_RejectsInvalidRules(t *testing.T) { + for name, tc := range map[string]struct{ rewardType, rules string }{ + "unknown type": {"TIERED", `{}`}, + "not JSON": {constants.GameRewardTypeFixed, `{amount: 5}`}, + "unknown field": {constants.GameRewardTypeFixed, `{"amount": 5, "amonut": 6}`}, + "trailing data": {constants.GameRewardTypeFixed, `{"amount": 5} {}`}, + "FIXED without amount": {constants.GameRewardTypeFixed, `{}`}, + "FIXED negative": {constants.GameRewardTypeFixed, `{"amount": -1}`}, + "FIXED fraction": {constants.GameRewardTypeFixed, `{"amount": 1.5}`}, + "no bands": {constants.GameRewardTypeScoreBased, `{"bands": []}`}, + "bands not from 0": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 1, "amount": 1}]}`}, + "bands overlap": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 100, "amount": 2}]}`}, + "bands leave a gap": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 102, "amount": 2}]}`}, + "open band not last": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "amount": 1}, {"min": 101, "amount": 2}]}`}, + "band max below min": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": -1, "amount": 1}]}`}, + "band out of order": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 501, "amount": 3}, {"min": 101, "max": 500, "amount": 2}]}`}, + "band without amount": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0}]}`}, + "band negative amount": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "amount": -2}]}`}, + "no outcomes": {constants.GameRewardTypeOutcomeBased, `{"outcomes": {}}`}, + "blank outcome": {constants.GameRewardTypeOutcomeBased, `{"outcomes": {" ": 1}}`}, + "negative outcome": {constants.GameRewardTypeOutcomeBased, `{"outcomes": {"WIN": -1}}`}, + "empty table": {constants.GameRewardTypeProbability, `{"table": []}`}, + "zero weight": {constants.GameRewardTypeProbability, `{"table": [{"weight": 0, "amount": 1}, {"weight": 1, "amount": 0}]}`}, + "negative weight": {constants.GameRewardTypeProbability, `{"table": [{"weight": -1, "amount": 1}, {"weight": 2, "amount": 0}]}`}, + "percent weight": {constants.GameRewardTypeProbability, `{"table": [{"weight": 0.1, "amount": 1000}]}`}, + "entry without weight": {constants.GameRewardTypeProbability, `{"table": [{"amount": 1}]}`}, + "negative probability pay": {constants.GameRewardTypeProbability, `{"table": [{"weight": 1, "amount": -1}]}`}, + "weights overflow": {constants.GameRewardTypeProbability, `{"table": [{"weight": 9000000000000000000, "amount": 1}, {"weight": 9000000000000000000, "amount": 0}]}`}, + } { + c, err := RewardCalculatorFor(tc.rewardType) + if err == nil { + err = c.Validate(json.RawMessage(tc.rules)) + } + assert.ErrorIs(t, err, ErrInvalidRewardRules, name) + } +} diff --git a/internal/processor/voucher_admin_processor.go b/internal/processor/voucher_admin_processor.go new file mode 100644 index 0000000..9115aa6 --- /dev/null +++ b/internal/processor/voucher_admin_processor.go @@ -0,0 +1,422 @@ +package processor + +import ( + "bytes" + "context" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "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" +) + +const ( + // voucherCodeImportLimit caps the lines of one import. + voucherCodeImportLimit = 50_000 + // voucherDuplicateReportLimit caps the duplicate codes an import lists back. + voucherDuplicateReportLimit = 100 +) + +// VoucherAdminProcessor manages an organization's EnakGame vouchers and their code +// pools (docs/rfc-enakgame.md §5.7, §11). Every change is audited in its transaction. +type VoucherAdminProcessor struct { + vouchers repository.VoucherRepository + audit *AuditLogger + tx TxRunner +} + +func NewVoucherAdminProcessor(vouchers repository.VoucherRepository, audit *AuditLogger, tx TxRunner) *VoucherAdminProcessor { + return &VoucherAdminProcessor{vouchers: vouchers, audit: audit, tx: tx} +} + +func (p *VoucherAdminProcessor) record(ctx context.Context, organizationID, actor, id uuid.UUID, action string, before, after any, reason *string) error { + return p.audit.Record(ctx, AuditEntry{ + OrganizationID: organizationID, ActorType: constants.AuditActorUser, ActorID: &actor, + EntityType: constants.AuditEntityVoucher, EntityID: id, Action: action, + Before: before, After: after, Reason: reason, Source: constants.AuditSourceAdminAPI, + }) +} + +// VoucherInputFrom is a voucher's current values as an input, for a change that only +// sends the fields it changes. +func VoucherInputFrom(v *models.Voucher) models.VoucherInput { + return models.VoucherInput{ + Name: v.Name, Description: v.Description, ImageURL: v.ImageURL, VoucherType: v.VoucherType, + FaceValue: v.FaceValue, PointCost: v.PointCost, BusinessCost: v.BusinessCost, StockMode: v.StockMode, + Stock: v.Stock, Provider: v.Provider, ProviderRef: v.ProviderRef, MaxPerCustomer: v.MaxPerCustomer, + ValidFrom: v.ValidFrom, ValidUntil: v.ValidUntil, Terms: v.Terms, Status: v.Status, + } +} + +func (p *VoucherAdminProcessor) CreateVoucher(ctx context.Context, organizationID, actor uuid.UUID, in models.VoucherInput) (*models.Voucher, error) { + if in.Status == "" { + in.Status = constants.VoucherStatusDraft + } + in.Status = strings.ToUpper(strings.TrimSpace(in.Status)) + switch in.Status { + case constants.VoucherStatusDraft, constants.VoucherStatusActive, constants.VoucherStatusInactive: + default: + return nil, enakGameRejected("a new voucher must be DRAFT, ACTIVE or INACTIVE") + } + voucher := &entities.Voucher{OrganizationID: organizationID, Status: in.Status} + if err := applyVoucherInput(voucher, in); err != nil { + return nil, err + } + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if err := p.vouchers.CreateVoucher(ctx, voucher); err != nil { + return err + } + return p.record(ctx, organizationID, actor, voucher.ID, "CREATED", nil, voucherModel(voucher), nil) + }) + if err != nil { + return nil, err + } + return voucherModel(voucher), nil +} + +func (p *VoucherAdminProcessor) GetVoucher(ctx context.Context, organizationID, id uuid.UUID) (*models.Voucher, error) { + voucher, err := p.vouchers.GetVoucher(ctx, organizationID, id) + if err != nil { + return nil, err + } + return voucherModel(voucher), nil +} + +// ListVouchers returns a page of vouchers. ARCHIVED ones are left out unless asked +// for by status. +func (p *VoucherAdminProcessor) ListVouchers(ctx context.Context, organizationID uuid.UUID, q models.VoucherListQuery) (*models.PaginatedResponse[models.Voucher], error) { + page, limit := enakGamePage(q.Page, q.Limit) + statuses := []string{constants.VoucherStatusDraft, constants.VoucherStatusActive, constants.VoucherStatusInactive} + if q.Status != "" { + status := strings.ToUpper(strings.TrimSpace(q.Status)) + if !isVoucherStatus(status) { + return nil, enakGameRejected("unknown status %q", q.Status) + } + statuses = []string{status} + } + vouchers, total, err := p.vouchers.ListVouchers(ctx, repository.VoucherFilter{ + OrganizationID: organizationID, Statuses: statuses, Search: strings.TrimSpace(q.Search), + Offset: (page - 1) * limit, Limit: limit, + }) + if err != nil { + return nil, err + } + items := make([]models.Voucher, 0, len(vouchers)) + for i := range vouchers { + items = append(items, *voucherModel(&vouchers[i])) + } + return &models.PaginatedResponse[models.Voucher]{Data: items, Pagination: enakGamePagination(page, limit, total)}, nil +} + +// UpdateVoucher changes everything but the stock mode and the status. Redemptions +// already made keep the numbers they froze. +func (p *VoucherAdminProcessor) UpdateVoucher(ctx context.Context, organizationID, actor, id uuid.UUID, in models.VoucherInput) (*models.Voucher, error) { + var after *models.Voucher + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + voucher, err := p.vouchers.LockVoucher(ctx, organizationID, id) + if err != nil { + return err + } + if voucher.Status == constants.VoucherStatusArchived { + return enakGameRejected("an archived voucher cannot change") + } + if !strings.EqualFold(strings.TrimSpace(in.StockMode), voucher.StockMode) { + return enakGameRejected("the stock mode of a voucher cannot change") + } + before := voucherModel(voucher) + if err := applyVoucherInput(voucher, in); err != nil { + return err + } + if err := p.vouchers.UpdateVoucher(ctx, voucher); err != nil { + return err + } + saved, err := p.vouchers.GetVoucher(ctx, organizationID, id) + if err != nil { + return err + } + after = voucherModel(saved) + return p.record(ctx, organizationID, actor, id, "UPDATED", before, after, nil) + }) + if err != nil { + return nil, err + } + return after, nil +} + +// SetVoucherStatus moves a voucher between DRAFT, ACTIVE and INACTIVE, or archives it +// for good. +func (p *VoucherAdminProcessor) SetVoucherStatus(ctx context.Context, organizationID, actor, id uuid.UUID, in models.VoucherStatusInput) (*models.Voucher, error) { + status := strings.ToUpper(strings.TrimSpace(in.Status)) + if !isVoucherStatus(status) { + return nil, enakGameRejected("status must be DRAFT, ACTIVE, INACTIVE or ARCHIVED") + } + if err := validateReason(in.Reason); err != nil { + return nil, err + } + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + voucher, err := p.vouchers.LockVoucher(ctx, organizationID, id) + if err != nil { + return err + } + if voucher.Status == status { + return nil + } + if voucher.Status == constants.VoucherStatusArchived { + return enakGameRejected("an archived voucher cannot change") + } + if err := p.vouchers.SetVoucherStatus(ctx, organizationID, id, status); err != nil { + return err + } + return p.record(ctx, organizationID, actor, id, "STATUS_CHANGED", + map[string]string{"status": voucher.Status}, map[string]string{"status": status}, in.Reason) + }) + if err != nil { + return nil, err + } + return p.GetVoucher(ctx, organizationID, id) +} + +// ImportCodes adds codes from a CSV to a CODE_POOL voucher. Each line holds a code and, +// optionally, when it expires (YYYY-MM-DD, the end of that day in Asia/Jakarta, or +// RFC 3339); a first line "code" is a header. A code already in the pool or repeated in +// the file is skipped and listed back, so importing the same file twice adds nothing +// the second time. A line that cannot be read is skipped and reported. +func (p *VoucherAdminProcessor) ImportCodes(ctx context.Context, organizationID, actor, voucherID uuid.UUID, data []byte) (*models.VoucherCodeImportResult, error) { + codes, invalid, repeated, err := parseVoucherCodes(data) + if err != nil { + return nil, err + } + result := &models.VoucherCodeImportResult{Duplicates: []string{}, Invalid: invalid} + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + voucher, err := p.vouchers.LockVoucher(ctx, organizationID, voucherID) + if err != nil { + return err + } + if voucher.StockMode != constants.VoucherStockCodePool { + return enakGameRejected("only a CODE_POOL voucher holds codes") + } + if voucher.Status == constants.VoucherStatusArchived { + return enakGameRejected("an archived voucher cannot change") + } + added, err := p.vouchers.ImportCodes(ctx, voucherID, codes) + if err != nil { + return err + } + addedSet := make(map[string]bool, len(added)) + for _, code := range added { + addedSet[code] = true + } + duplicates := repeated + for _, c := range codes { + if !addedSet[c.Code] { + duplicates = append(duplicates, c.Code) + } + } + result.Imported = len(added) + result.DuplicateCount = len(duplicates) + if len(duplicates) > voucherDuplicateReportLimit { + duplicates = duplicates[:voucherDuplicateReportLimit] + } + result.Duplicates = append(result.Duplicates, duplicates...) + return p.record(ctx, organizationID, actor, voucherID, "CODES_IMPORTED", nil, map[string]int{ + "imported": result.Imported, "duplicates": result.DuplicateCount, "invalid": len(result.Invalid), + }, nil) + }) + if err != nil { + return nil, err + } + return result, nil +} + +// Codes returns how many of a pool's codes are in each status and a page of them. +func (p *VoucherAdminProcessor) Codes(ctx context.Context, organizationID, voucherID uuid.UUID, q models.VoucherCodeListQuery) (*models.VoucherCodes, error) { + if _, err := p.vouchers.GetVoucher(ctx, organizationID, voucherID); err != nil { + return nil, err + } + status := strings.ToUpper(strings.TrimSpace(q.Status)) + switch status { + case "", constants.VoucherCodeAvailable, constants.VoucherCodeReserved, constants.VoucherCodeRedeemed, + constants.VoucherCodeExpired, constants.VoucherCodeCancelled: + default: + return nil, enakGameRejected("unknown code status %q", q.Status) + } + counts, err := p.vouchers.CountCodes(ctx, voucherID) + if err != nil { + return nil, err + } + page, limit := enakGamePage(q.Page, q.Limit) + codes, total, err := p.vouchers.ListCodes(ctx, voucherID, status, (page-1)*limit, limit) + if err != nil { + return nil, err + } + items := make([]models.VoucherCode, 0, len(codes)) + for _, c := range codes { + items = append(items, models.VoucherCode{ID: c.ID, Code: c.Code, Status: c.Status, RedemptionID: c.RedemptionID, ExpiresAt: c.ExpiresAt, CreatedAt: c.CreatedAt}) + } + return &models.VoucherCodes{ + Counts: counts, + Codes: models.PaginatedResponse[models.VoucherCode]{Data: items, Pagination: enakGamePagination(page, limit, total)}, + }, nil +} + +// ExpireCodes moves every available code past its expiry to EXPIRED, a batch at a +// time, and returns how many (§12). +func (p *VoucherAdminProcessor) ExpireCodes(ctx context.Context, now time.Time) (int64, error) { + const batch = 500 + var expired int64 + for { + n, err := p.vouchers.ExpireCodes(ctx, now, batch) + expired += n + if err != nil || n < batch { + return expired, err + } + } +} + +// parseVoucherCodes reads codes from a CSV. It returns the codes, the lines it could +// not read, and the codes repeated within the file. +func parseVoucherCodes(data []byte) ([]repository.VoucherCodeImport, []models.VoucherCodeImportProblem, []string, error) { + reader := csv.NewReader(bytes.NewReader(data)) + reader.FieldsPerRecord = -1 + reader.TrimLeadingSpace = true + var codes []repository.VoucherCodeImport + invalid := []models.VoucherCodeImportProblem{} + var repeated []string + seen := map[string]bool{} + for line := 1; ; line++ { + record, err := reader.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + invalid = append(invalid, models.VoucherCodeImportProblem{Line: line, Reason: err.Error()}) + continue + } + if line > voucherCodeImportLimit { + return nil, nil, nil, enakGameRejected("an import takes at most %d lines", voucherCodeImportLimit) + } + code := strings.TrimSpace(record[0]) + if line == 1 && strings.EqualFold(code, "code") { + continue + } + if code == "" { + continue + } + if len(code) > 255 { + invalid = append(invalid, models.VoucherCodeImportProblem{Line: line, Reason: "the code is longer than 255 characters"}) + continue + } + var expiresAt *time.Time + if len(record) > 1 && strings.TrimSpace(record[1]) != "" { + t, err := parseCodeExpiry(strings.TrimSpace(record[1])) + if err != nil { + invalid = append(invalid, models.VoucherCodeImportProblem{Line: line, Reason: err.Error()}) + continue + } + expiresAt = &t + } + if seen[code] { + repeated = append(repeated, code) + continue + } + seen[code] = true + codes = append(codes, repository.VoucherCodeImport{Code: code, ExpiresAt: expiresAt}) + } + if len(codes) == 0 && len(invalid) == 0 { + return nil, nil, nil, enakGameRejected("the file holds no codes") + } + return codes, invalid, repeated, nil +} + +func parseCodeExpiry(raw string) (time.Time, error) { + if day, err := time.ParseInLocation("2006-01-02", raw, walletDisplayLocation); err == nil { + return *endOfWalletDay(day), nil + } + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return t, nil + } + return time.Time{}, fmt.Errorf("expiry %q is not a date like 2026-12-31", raw) +} + +func isVoucherStatus(s string) bool { + switch s { + case constants.VoucherStatusDraft, constants.VoucherStatusActive, constants.VoucherStatusInactive, constants.VoucherStatusArchived: + return true + } + return false +} + +// applyVoucherInput checks an input and copies it onto the voucher, leaving its +// organization, stock mode (once set) and status alone. +func applyVoucherInput(v *entities.Voucher, in models.VoucherInput) error { + name := strings.TrimSpace(in.Name) + voucherType := strings.ToUpper(strings.TrimSpace(in.VoucherType)) + stockMode := strings.ToUpper(strings.TrimSpace(in.StockMode)) + switch { + case name == "" || len(name) > 255: + return enakGameRejected("name is required, at most 255 characters") + case voucherType != constants.VoucherTypeFixedValue && voucherType != constants.VoucherTypePercentage && + voucherType != constants.VoucherTypeFreeItem && voucherType != constants.VoucherTypeMerchantBenefit: + return enakGameRejected("voucher_type must be FIXED_VALUE, PERCENTAGE, FREE_ITEM or MERCHANT_BENEFIT") + case in.FaceValue <= 0: + return enakGameRejected("face_value must be more than 0") + case in.PointCost <= 0: + return enakGameRejected("point_cost must be more than 0") + case in.BusinessCost != nil && *in.BusinessCost < 0: + return enakGameRejected("business_cost must be 0 or more") + case stockMode != constants.VoucherStockStatic && stockMode != constants.VoucherStockCodePool && stockMode != constants.VoucherStockExternal: + return enakGameRejected("stock_mode must be STATIC, CODE_POOL or EXTERNAL") + case (stockMode == constants.VoucherStockStatic) != (in.Stock != nil): + return enakGameRejected("stock is required for a STATIC voucher and only for one") + case in.Stock != nil && *in.Stock < 0: + return enakGameRejected("stock must be 0 or more") + case (stockMode == constants.VoucherStockExternal) != (in.Provider != nil && strings.TrimSpace(*in.Provider) != ""): + return enakGameRejected("provider is required for an EXTERNAL voucher and only for one") + case in.Provider != nil && len(*in.Provider) > 50: + return enakGameRejected("provider must be at most 50 characters") + case in.ProviderRef != nil && len(*in.ProviderRef) > 255: + return enakGameRejected("provider_ref must be at most 255 characters") + case in.MaxPerCustomer != nil && *in.MaxPerCustomer < 1: + return enakGameRejected("max_per_customer must be at least 1, or left out for no limit") + case in.ValidFrom != nil && in.ValidUntil != nil && !in.ValidUntil.After(*in.ValidFrom): + return enakGameRejected("valid_until must be after valid_from") + case in.ImageURL != nil && len(*in.ImageURL) > 500: + return enakGameRejected("image_url must be at most 500 characters") + } + terms := entities.JSONDocument(`{}`) + if len(bytes.TrimSpace(in.Terms)) > 0 && string(bytes.TrimSpace(in.Terms)) != "null" { + var object map[string]any + if err := json.Unmarshal(in.Terms, &object); err != nil { + return enakGameRejected("terms must be a JSON object") + } + var compact bytes.Buffer + if err := json.Compact(&compact, in.Terms); err != nil { + return enakGameRejected("terms must be a JSON object") + } + terms = entities.JSONDocument(compact.Bytes()) + } + v.Name, v.Description, v.ImageURL, v.VoucherType = name, in.Description, in.ImageURL, voucherType + v.FaceValue, v.PointCost, v.BusinessCost, v.StockMode = in.FaceValue, in.PointCost, in.BusinessCost, stockMode + v.Stock, v.Provider, v.ProviderRef, v.MaxPerCustomer = in.Stock, in.Provider, in.ProviderRef, in.MaxPerCustomer + v.ValidFrom, v.ValidUntil, v.Terms = in.ValidFrom, in.ValidUntil, terms + return nil +} + +func voucherModel(v *entities.Voucher) *models.Voucher { + return &models.Voucher{ + ID: v.ID, Name: v.Name, Description: v.Description, ImageURL: v.ImageURL, VoucherType: v.VoucherType, + FaceValue: v.FaceValue, PointCost: v.PointCost, BusinessCost: v.BusinessCost, StockMode: v.StockMode, + Stock: v.Stock, Provider: v.Provider, ProviderRef: v.ProviderRef, MaxPerCustomer: v.MaxPerCustomer, + ValidFrom: v.ValidFrom, ValidUntil: v.ValidUntil, Terms: json.RawMessage(v.Terms), Status: v.Status, + CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, + } +} diff --git a/internal/processor/voucher_cost.go b/internal/processor/voucher_cost.go new file mode 100644 index 0000000..6c8ee16 --- /dev/null +++ b/internal/processor/voucher_cost.go @@ -0,0 +1,43 @@ +package processor + +import ( + "math/big" + + "apskel-pos-be/internal/repository" +) + +// VoucherCostPart is the share of a redemption's face value one source of its +// EnakPoint accounts for (docs/rfc-enakgame.md §7.6). +type VoucherCostPart struct { + repository.RedemptionPointSource + Cost int64 +} + +// SplitVoucherCost divides faceValue over the sources in proportion to their +// EnakPoint: floor(face_value × points / total points) each, with what rounding left +// over going to the largest part (the first of equals), so the parts add up to +// face_value exactly. Products are computed without overflow. +func SplitVoucherCost(faceValue int64, sources []repository.RedemptionPointSource) []VoucherCostPart { + parts := make([]VoucherCostPart, 0, len(sources)) + var totalPoints int64 + for _, s := range sources { + totalPoints += s.Points + } + if totalPoints <= 0 { + return parts + } + face, total := big.NewInt(faceValue), big.NewInt(totalPoints) + var assigned int64 + largest := 0 + for i, s := range sources { + share := new(big.Int).Mul(face, big.NewInt(s.Points)) + share.Quo(share, total) + parts = append(parts, VoucherCostPart{RedemptionPointSource: s, Cost: share.Int64()}) + assigned += share.Int64() + if s.Points > sources[largest].Points { + largest = i + } + } + parts[largest].Cost += faceValue - assigned + return parts +} diff --git a/internal/processor/voucher_cost_test.go b/internal/processor/voucher_cost_test.go new file mode 100644 index 0000000..34bb287 --- /dev/null +++ b/internal/processor/voucher_cost_test.go @@ -0,0 +1,52 @@ +package processor + +import ( + "math/rand" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/repository" +) + +func TestSplitVoucherCost(t *testing.T) { + october := uuid.New() + // RFC §7.6: Rp10.000 voucher for 8.000 EnakPoint, 6.000 from an EnakGame reward + // and 2.000 from shopping. + parts := SplitVoucherCost(10_000, []repository.RedemptionPointSource{ + {Points: 2_000, SourceType: constants.WalletTxTypeEarn}, + {Points: 6_000, SourceType: constants.WalletTxTypeGameReward, BudgetID: &october}, + }) + assert.EqualValues(t, 2_500, parts[0].Cost) + assert.EqualValues(t, 7_500, parts[1].Cost) + + // Not evenly divisible: the remainder goes to the largest part, the first of equals. + parts = SplitVoucherCost(10_000, []repository.RedemptionPointSource{{Points: 3}, {Points: 3}, {Points: 3}}) + assert.Equal(t, []int64{3_334, 3_333, 3_333}, []int64{parts[0].Cost, parts[1].Cost, parts[2].Cost}) + parts = SplitVoucherCost(100, []repository.RedemptionPointSource{{Points: 1}, {Points: 5}, {Points: 1}}) + assert.Equal(t, []int64{14, 72, 14}, []int64{parts[0].Cost, parts[1].Cost, parts[2].Cost}) + + // No overflow on large numbers. + parts = SplitVoucherCost(9_000_000_000_000, []repository.RedemptionPointSource{{Points: 3_000_000_000_000}, {Points: 6_000_000_000_000}}) + assert.Equal(t, []int64{3_000_000_000_000, 6_000_000_000_000}, []int64{parts[0].Cost, parts[1].Cost}) + + assert.Empty(t, SplitVoucherCost(10, nil)) + + // Whatever the numbers, the parts add up to the face value and none is negative. + rng := rand.New(rand.NewSource(7)) + for i := 0; i < 2_000; i++ { + face := rng.Int63n(1_000_000) + 1 + sources := make([]repository.RedemptionPointSource, rng.Intn(5)+1) + for j := range sources { + sources[j].Points = rng.Int63n(10_000) + 1 + } + var sum int64 + for _, p := range SplitVoucherCost(face, sources) { + assert.GreaterOrEqual(t, p.Cost, int64(0)) + sum += p.Cost + } + assert.Equal(t, face, sum) + } +} diff --git a/internal/processor/voucher_db_test.go b/internal/processor/voucher_db_test.go new file mode 100644 index 0000000..0a719ba --- /dev/null +++ b/internal/processor/voucher_db_test.go @@ -0,0 +1,477 @@ +package processor + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// EnakGame vouchers and redemptions against Postgres (docs/rfc-enakgame.md §7.4, §7.6). +// Needs TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go. + +func (e *enakGameEnv) voucher(org uuid.UUID, in models.VoucherInput) *models.Voucher { + e.t.Helper() + if in.Name == "" { + in.Name = "Voucher" + } + if in.VoucherType == "" { + in.VoucherType = constants.VoucherTypeFixedValue + } + if in.Status == "" { + in.Status = constants.VoucherStatusActive + } + v, err := e.vouchers.CreateVoucher(e.ctx(), org, e.manager, in) + require.NoError(e.t, err) + return v +} + +func (e *enakGameEnv) staticVoucher(stock, faceValue, pointCost int64) *models.Voucher { + return e.voucher(e.orgA, models.VoucherInput{FaceValue: faceValue, PointCost: pointCost, StockMode: constants.VoucherStockStatic, Stock: &stock}) +} + +// points gives a customer EnakPoint that did not come from EnakGame. +func (e *enakGameEnv) points(customer uuid.UUID, txType string, amount int64) { + e.t.Helper() + entry := WalletEntry{ + CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: txType, Amount: amount, + ReferenceID: uuid.New(), Description: txType, + } + switch txType { + case constants.WalletTxTypeEarn: + entry.ReferenceType, entry.OutletID = constants.WalletRefTypeOrder, ptr(uuid.New()) + default: + entry.ReferenceType, entry.CreatedByUser, entry.Reason = constants.WalletRefTypeUser, &e.manager, ptr("test") + } + require.NoError(e.t, e.txm.WithTransaction(e.ctx(), func(ctx context.Context) error { + _, err := e.wallet.Credit(ctx, WalletCreditInput{WalletEntry: entry}) + return err + })) +} + +// rewardCoins plays a FIXED game of org A paying amount and returns the global budget +// that paid it. +func (e *enakGameEnv) rewardCoins(customer uuid.UUID, amount int64) uuid.UUID { + e.t.Helper() + slug := fmt.Sprintf("pay-%d-%s", amount, uuid.NewString()[:8]) + game := e.gameWith(slug, entities.GameResultRules{}, constants.GameRewardTypeFixed, fmt.Sprintf(`{"amount": %d}`, amount), 0) + e.coins(customer, 2, nil) + started, err := e.sessions.Start(e.ctx(), customer, game.ID, slug) + require.NoError(e.t, err) + out, err := e.sessions.Complete(e.ctx(), customer, started.SessionID, models.GameSessionCompleteInput{}) + require.NoError(e.t, err) + require.EqualValues(e.t, amount, out.RewardTotal) + var budget string + require.NoError(e.t, e.db.Raw(`SELECT budget_id::text FROM game_session_rewards WHERE session_id = ?`, started.SessionID).Scan(&budget).Error) + return uuid.MustParse(budget) +} + +func (e *enakGameEnv) exchangeCoins(customer uuid.UUID, coins int64) { + e.t.Helper() + _, err := e.exchange.Exchange(e.ctx(), customer, coins, testPin, uuid.NewString(), models.CustomerPinRequestInfo{}) + require.NoError(e.t, err) +} + +func (e *enakGameEnv) pointBalance(customer uuid.UUID) int64 { + e.t.Helper() + var balance int64 + require.NoError(e.t, e.db.Raw(`SELECT COALESCE((SELECT point_balance FROM customer_wallets WHERE customer_id = ?), 0)`, customer).Scan(&balance).Error) + return balance +} + +func (e *enakGameEnv) redeemVoucher(customer, voucherID uuid.UUID, key string) (*models.VoucherRedeemResult, error) { + return e.redeem.Redeem(e.ctx(), customer, voucherID, testPin, key, models.CustomerPinRequestInfo{}) +} + +type costRow struct { + BudgetID *string + SourceType string + Points int64 + Cost int64 +} + +func (e *enakGameEnv) costs(redemptionID uuid.UUID) []costRow { + e.t.Helper() + var rows []costRow + require.NoError(e.t, e.db.Raw(`SELECT budget_id::text AS budget_id, source_type, points, cost FROM voucher_redemption_costs + WHERE redemption_id = ? ORDER BY cost DESC`, redemptionID).Scan(&rows).Error) + return rows +} + +// EG-502 +func TestVoucherAdmin(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + + for name, in := range map[string]models.VoucherInput{ + "no name": {VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1))}, + "unknown type": {Name: "v", VoucherType: "CASHBACK", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1))}, + "no face value": {Name: "v", VoucherType: "FIXED_VALUE", PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1))}, + "no point cost": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, StockMode: "STATIC", Stock: ptr(int64(1))}, + "static no stock": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "STATIC"}, + "pool with stock": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", Stock: ptr(int64(1))}, + "external no provdr": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "EXTERNAL"}, + "max per customer 0": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", MaxPerCustomer: ptr(0)}, + "ends before it ends": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", ValidFrom: ptr(e.now), ValidUntil: ptr(e.now)}, + "terms not an object": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", Terms: json.RawMessage(`[1]`)}, + "archived on create": {Name: "v", VoucherType: "FIXED_VALUE", FaceValue: 1, PointCost: 1, StockMode: "CODE_POOL", Status: "ARCHIVED"}, + } { + _, err := e.vouchers.CreateVoucher(ctx, e.orgA, e.manager, in) + assert.ErrorIs(t, err, ErrEnakGameRejected, name) + } + + pool := e.voucher(e.orgA, models.VoucherInput{Name: "Kopi", FaceValue: 20_000, PointCost: 15_000, StockMode: "code_pool", + Terms: json.RawMessage(`{"outlet": "semua"}`), Status: "DRAFT"}) + assert.Equal(t, constants.VoucherStockCodePool, pool.StockMode) + assert.JSONEq(t, `{"outlet": "semua"}`, string(pool.Terms)) + + // Importing the same file twice adds nothing the second time. + csv := []byte("code,expires_at\nKOPI-1,2030-12-31\nKOPI-2\nKOPI-2\n,\nKOPI-3,31/12/2030\nKOPI-4\n") + result, err := e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, csv) + require.NoError(t, err) + assert.Equal(t, 3, result.Imported) + assert.Equal(t, []string{"KOPI-2"}, result.Duplicates, "repeated in the file") + require.Len(t, result.Invalid, 1) + assert.Equal(t, 6, result.Invalid[0].Line) + result, err = e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, csv) + require.NoError(t, err) + assert.Zero(t, result.Imported) + assert.Equal(t, 4, result.DuplicateCount) + codes, err := e.vouchers.Codes(ctx, e.orgA, pool.ID, models.VoucherCodeListQuery{}) + require.NoError(t, err) + assert.Equal(t, map[string]int64{"AVAILABLE": 3}, codes.Counts) + require.Len(t, codes.Codes.Data, 3) + var expiring *time.Time + for _, c := range codes.Codes.Data { + if c.Code == "KOPI-1" { + expiring = c.ExpiresAt + } + } + require.NotNil(t, expiring) + assert.Equal(t, "2030-12-31 23:59:59", expiring.In(walletDisplayLocation).Format("2006-01-02 15:04:05"), "the end of that day in Jakarta") + + static := e.staticVoucher(5, 10_000, 8_000) + _, err = e.vouchers.ImportCodes(ctx, e.orgA, e.manager, static.ID, csv) + assert.ErrorIs(t, err, ErrEnakGameRejected, "a STATIC voucher holds no codes") + _, err = e.vouchers.ImportCodes(ctx, e.orgB, e.manager, pool.ID, csv) + assert.ErrorIs(t, err, repository.ErrVoucherNotFound, "another organization's voucher") + _, err = e.vouchers.GetVoucher(ctx, e.orgB, pool.ID) + assert.ErrorIs(t, err, repository.ErrVoucherNotFound) + + in := VoucherInputFrom(static) + in.Stock, in.PointCost = ptr(int64(9)), 7_500 + updated, err := e.vouchers.UpdateVoucher(ctx, e.orgA, e.manager, static.ID, in) + require.NoError(t, err) + assert.EqualValues(t, 9, *updated.Stock) + in.StockMode = constants.VoucherStockCodePool + _, err = e.vouchers.UpdateVoucher(ctx, e.orgA, e.manager, static.ID, in) + assert.ErrorIs(t, err, ErrEnakGameRejected, "the stock mode stays") + + _, err = e.vouchers.SetVoucherStatus(ctx, e.orgA, e.manager, static.ID, models.VoucherStatusInput{Status: "ARCHIVED"}) + require.NoError(t, err) + _, err = e.vouchers.UpdateVoucher(ctx, e.orgA, e.manager, static.ID, VoucherInputFrom(updated)) + assert.ErrorIs(t, err, ErrEnakGameRejected) + list, err := e.vouchers.ListVouchers(ctx, e.orgA, models.VoucherListQuery{}) + require.NoError(t, err) + assert.Len(t, list.Data, 1, "archived left out") + assert.Equal(t, []string{"CREATED", "UPDATED", "STATUS_CHANGED"}, e.auditActions(constants.AuditEntityVoucher, static.ID)) + assert.Equal(t, []string{"CREATED", "CODES_IMPORTED", "CODES_IMPORTED"}, e.auditActions(constants.AuditEntityVoucher, pool.ID)) +} + +// EG-504 +func TestVoucherRedemption(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + e.points(e.alice, constants.WalletTxTypeAdjustment, 100_000) + e.points(e.bob, constants.WalletTxTypeAdjustment, 100_000) + + t.Run("STATIC: the last one goes to exactly one of two customers", func(t *testing.T) { + last := e.staticVoucher(1, 10_000, 8_000) + var wg sync.WaitGroup + results := make([]error, 2) + for i, customer := range []uuid.UUID{e.alice, e.bob} { + i, customer := i, customer + wg.Add(1) + go func() { + defer wg.Done() + _, results[i] = e.redeemVoucher(customer, last.ID, "last") + }() + } + wg.Wait() + won := 0 + for _, err := range results { + if err == nil { + won++ + } else { + assert.ErrorIs(t, err, ErrVoucherRedemptionRejected) + } + } + assert.Equal(t, 1, won) + assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM voucher_redemptions WHERE voucher_id = ?`, last.ID)) + assert.EqualValues(t, 200_000-8_000, e.pointBalance(e.alice)+e.pointBalance(e.bob), "only the winner paid") + assert.Zero(t, e.count(`SELECT stock FROM vouchers WHERE id = ?`, last.ID)) + }) + + t.Run("CODE_POOL: redemptions at once get different codes", func(t *testing.T) { + pool := e.voucher(e.orgA, models.VoucherInput{FaceValue: 5_000, PointCost: 1_000, StockMode: constants.VoucherStockCodePool}) + _, err := e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, []byte("A1\nA2\nA3\nA4\nA5\nA6")) + require.NoError(t, err) + var wg sync.WaitGroup + var mu sync.Mutex + got := map[string]int{} + for i := 0; i < 6; i++ { + customer := []uuid.UUID{e.alice, e.bob}[i%2] + key := fmt.Sprintf("pool-%d", i) + wg.Add(1) + go func() { + defer wg.Done() + res, err := e.redeemVoucher(customer, pool.ID, key) + if assert.NoError(t, err) { + mu.Lock() + got[*res.Code]++ + mu.Unlock() + } + }() + } + wg.Wait() + assert.Len(t, got, 6, "six different codes") + for code, n := range got { + assert.Equal(t, 1, n, code) + } + _, err = e.redeemVoucher(e.alice, pool.ID, "pool-empty") + assert.ErrorIs(t, err, ErrVoucherRedemptionRejected, "the pool is empty") + }) + + t.Run("the same key twice: one debit, one code", func(t *testing.T) { + pool := e.voucher(e.orgA, models.VoucherInput{FaceValue: 5_000, PointCost: 1_000, StockMode: constants.VoucherStockCodePool}) + _, err := e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, []byte("B1\nB2")) + require.NoError(t, err) + before := e.pointBalance(e.alice) + first, err := e.redeemVoucher(e.alice, pool.ID, "twice") + require.NoError(t, err) + assert.False(t, first.Replayed) + require.NotNil(t, first.Code) + assert.EqualValues(t, before-1_000, first.PointBalance) + // The voucher is turned off meanwhile; the retry still gets its code. + _, err = e.vouchers.SetVoucherStatus(ctx, e.orgA, e.manager, pool.ID, models.VoucherStatusInput{Status: "INACTIVE"}) + require.NoError(t, err) + second, err := e.redeemVoucher(e.alice, pool.ID, "twice") + require.NoError(t, err) + assert.True(t, second.Replayed) + assert.Equal(t, first.ID, second.ID) + assert.Equal(t, *first.Code, *second.Code) + assert.EqualValues(t, before-1_000, e.pointBalance(e.alice)) + assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'REWARD_REDEEM' AND reference_id = ?`, e.alice, first.ID)) + _, err = e.redeemVoucher(e.alice, uuid.New(), "twice") + assert.ErrorIs(t, err, ErrVoucherRedemptionRejected, "the key redeemed another voucher") + }) + + t.Run("refusals record nothing", func(t *testing.T) { + pricey := e.staticVoucher(5, 10_000, 1_000_000) + limited := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5)), MaxPerCustomer: ptr(1)}) + later := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5)), ValidFrom: ptr(e.now.Add(time.Hour))}) + ended := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5)), ValidUntil: ptr(e.now.Add(-time.Second))}) + draft := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5)), Status: "DRAFT"}) + external := e.voucher(e.orgA, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "EXTERNAL", Provider: ptr("no-adapter")}) + theirs := e.voucher(e.orgB, models.VoucherInput{FaceValue: 1_000, PointCost: 10, StockMode: "STATIC", Stock: ptr(int64(5))}) + _, err := e.redeemVoucher(e.bob, limited.ID, "limit-1") + require.NoError(t, err) + + count := func() int64 { + return e.count(`SELECT COUNT(*) FROM voucher_redemptions WHERE organization_id IN (?, ?)`, e.orgA, e.orgB) + + e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'REWARD_REDEEM' AND organization_id IN (?, ?)`, e.orgA, e.orgB) + } + before, balance := count(), e.pointBalance(e.bob) + for name, tc := range map[string]struct { + voucher uuid.UUID + pin string + want error + }{ + "not enough EnakPoint": {pricey.ID, testPin, ErrVoucherRedemptionRejected}, + "max per customer": {limited.ID, testPin, ErrVoucherRedemptionRejected}, + "not started": {later.ID, testPin, ErrVoucherRedemptionRejected}, + "ended": {ended.ID, testPin, ErrVoucherRedemptionRejected}, + "draft": {draft.ID, testPin, ErrVoucherRedemptionRejected}, + "external, no adapter": {external.ID, testPin, ErrVoucherRedemptionRejected}, + "another org": {theirs.ID, testPin, repository.ErrVoucherNotFound}, + "wrong PIN": {limited.ID, "000000", nil}, + } { + if name == "wrong PIN" { + // limited is used up for Bob; Alice has it free, so only the PIN stands in the way. + _, err := e.redeem.Redeem(ctx, e.alice, tc.voucher, tc.pin, "pin", models.CustomerPinRequestInfo{}) + var pinErr *PinError + assert.ErrorAs(t, err, &pinErr, name) + continue + } + _, err := e.redeem.Redeem(ctx, e.bob, tc.voucher, tc.pin, "refused-"+name, models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, tc.want, name) + } + assert.Equal(t, before, count(), "no redemption, no ledger row") + assert.Equal(t, balance, e.pointBalance(e.bob)) + assert.EqualValues(t, 5, e.count(`SELECT stock FROM vouchers WHERE id = ?`, pricey.ID), "the stock taken for a refused redemption is back") + assert.Zero(t, e.count(`SELECT pin_failed_attempts FROM customers WHERE id = ?`, e.bob), "a refusal before the PIN costs no attempt") + }) +} + +// EG-505 +func TestVoucherRedemption_CostAttribution(t *testing.T) { + e := newEnakGameEnv(t) + voucher := e.staticVoucher(100, 10_000, 8_000) + + t.Run("GAME_REWARD → exchange → redeem", func(t *testing.T) { + budget := e.rewardCoins(e.alice, 8_000) + e.exchangeCoins(e.alice, 8_000) + res, err := e.redeemVoucher(e.alice, voucher.ID, "chain-1") + require.NoError(t, err) + b := budget.String() + assert.Equal(t, []costRow{{BudgetID: &b, SourceType: "GAME_REWARD", Points: 8_000, Cost: 10_000}}, e.costs(res.ID)) + }) + + t.Run("GAME_REWARD → transfer → exchange → redeem", func(t *testing.T) { + budget := e.rewardCoins(e.alice, 8_000) + // Alice sends the reward to Bob, who exchanges and redeems it. + require.NoError(t, e.txm.WithTransaction(e.ctx(), func(ctx context.Context) error { + require.NoError(t, e.wallet.LockWallets(ctx, e.alice, e.bob)) + group, outID, inID := uuid.New(), uuid.New(), uuid.New() + out, err := e.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ + TransactionID: outID, CustomerID: e.alice, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeTransferOut, + Amount: 8_000, ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID, GroupID: &group, + CounterpartyCustomerID: &e.bob, Description: "ke Bob", + }}) + require.NoError(t, err) + _, err = e.wallet.Credit(ctx, WalletCreditInput{ + WalletEntry: WalletEntry{ + TransactionID: inID, CustomerID: e.bob, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeTransferIn, + Amount: 8_000, ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID, GroupID: &group, + CounterpartyCustomerID: &e.alice, Description: "dari Alice", + }, + Lots: out.CarryOver(), + }) + return err + })) + e.exchangeCoins(e.bob, 8_000) + res, err := e.redeemVoucher(e.bob, voucher.ID, "chain-2") + require.NoError(t, err) + b := budget.String() + assert.Equal(t, []costRow{{BudgetID: &b, SourceType: "GAME_REWARD", Points: 8_000, Cost: 10_000}}, e.costs(res.ID)) + }) + + t.Run("GAME_REWARD + EARN: only the reward counts against the budget", func(t *testing.T) { + // RFC §7.6: Rp10.000 for 8.000 EnakPoint, 6.000 from EnakGame and 2.000 from + // shopping: Rp7.500 to the budget, Rp2.500 to none. + dina := e.newCustomer(e.orgA) + budget := e.rewardCoins(dina, 6_000) + e.exchangeCoins(dina, 6_000) + e.points(dina, constants.WalletTxTypeEarn, 2_000) + res, err := e.redeemVoucher(dina, voucher.ID, "mixed") + require.NoError(t, err) + b := budget.String() + assert.Equal(t, []costRow{ + {BudgetID: &b, SourceType: "GAME_REWARD", Points: 6_000, Cost: 7_500}, + {BudgetID: nil, SourceType: "EARN", Points: 2_000, Cost: 2_500}, + }, e.costs(res.ID)) + var recognized time.Time + require.NoError(t, e.db.Raw(`SELECT recognized_at FROM voucher_redemption_costs WHERE redemption_id = ? LIMIT 1`, res.ID).Scan(&recognized).Error) + assert.WithinDuration(t, *res.CompletedAt, recognized, time.Millisecond, "recognized when the redemption completed") + }) + + t.Run("the costs add up to the face value when they do not divide evenly", func(t *testing.T) { + odd := e.staticVoucher(10, 1_000, 3) + eko := e.newCustomer(e.orgA) + budget := e.rewardCoins(eko, 1) + e.exchangeCoins(eko, 1) + e.points(eko, constants.WalletTxTypeEarn, 1) + e.points(eko, constants.WalletTxTypeAdjustment, 1) + res, err := e.redeemVoucher(eko, odd.ID, "odd") + require.NoError(t, err) + // 1.000 over three equal parts: 333 each, the rupiah left over to the first of + // equals, in source order. + b := budget.String() + assert.ElementsMatch(t, []costRow{ + {BudgetID: nil, SourceType: "ADJUSTMENT", Points: 1, Cost: 334}, + {BudgetID: nil, SourceType: "EARN", Points: 1, Cost: 333}, + {BudgetID: &b, SourceType: "GAME_REWARD", Points: 1, Cost: 333}, + }, e.costs(res.ID)) + }) +} + +// newCustomer adds a customer with the test PIN to org. +func (e *enakGameEnv) newCustomer(org uuid.UUID) uuid.UUID { + e.t.Helper() + id := uuid.New() + hash, err := bcrypt.GenerateFromPassword([]byte(testPin), bcrypt.MinCost) + require.NoError(e.t, err) + require.NoError(e.t, e.db.Exec(`INSERT INTO customers (id, organization_id, name, pin_hash, pin_set_at) VALUES (?, ?, 'extra', ?, NOW())`, id, org, string(hash)).Error) + return id +} + +// EG-506, EG-507 +func TestVoucherCatalogRedemptionsAndExpiry(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + e.points(e.alice, constants.WalletTxTypeAdjustment, 10_000) + + static := e.voucher(e.orgA, models.VoucherInput{Name: "A Static", FaceValue: 10_000, PointCost: 100, StockMode: "STATIC", Stock: ptr(int64(3))}) + pool := e.voucher(e.orgA, models.VoucherInput{Name: "B Pool", FaceValue: 5_000, PointCost: 200, StockMode: "CODE_POOL"}) + _, err := e.vouchers.ImportCodes(ctx, e.orgA, e.manager, pool.ID, []byte("P1\nP2\nP3,2020-01-01")) + require.NoError(t, err) + e.voucher(e.orgA, models.VoucherInput{Name: "Draft", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1)), Status: "DRAFT"}) + e.voucher(e.orgA, models.VoucherInput{Name: "External", FaceValue: 1, PointCost: 1, StockMode: "EXTERNAL", Provider: ptr("no-adapter")}) + e.voucher(e.orgA, models.VoucherInput{Name: "Ended", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1)), ValidUntil: ptr(e.now.Add(-time.Hour))}) + e.voucher(e.orgB, models.VoucherInput{Name: "Theirs", FaceValue: 1, PointCost: 1, StockMode: "STATIC", Stock: ptr(int64(1))}) + + catalog, err := e.redeem.Catalog(ctx, e.alice) + require.NoError(t, err) + require.Len(t, catalog, 2, "only ACTIVE vouchers of the organization within their dates that can be redeemed now") + byName := map[string]models.CustomerVoucher{} + for _, v := range catalog { + byName[v.Name] = v + } + assert.EqualValues(t, 3, *byName["A Static"].Available) + assert.EqualValues(t, 2, *byName["B Pool"].Available, "the expired code is not available") + raw, err := json.Marshal(catalog) + require.NoError(t, err) + for _, internal := range []string{"business_cost", "provider", "stock_mode", "REDEEMED", "EXPIRED", "counts"} { + assert.NotContains(t, string(raw), internal) + } + + _, err = e.redeemVoucher(e.alice, static.ID, "s1") + require.NoError(t, err) + got, err := e.redeemVoucher(e.alice, pool.ID, "p1") + require.NoError(t, err) + list, err := e.redeem.ListRedemptions(ctx, e.alice, 1, 10) + require.NoError(t, err) + require.Len(t, list.Data, 2) + assert.Equal(t, got.ID, list.Data[0].ID, "newest first") + assert.Equal(t, *got.Code, *list.Data[0].Code) + assert.Contains(t, []string{"P1", "P2"}, *got.Code, "never the expired P3") + assert.Equal(t, "B Pool", list.Data[0].VoucherName) + assert.Nil(t, list.Data[1].Code, "a STATIC voucher has no code") + other, err := e.redeem.ListRedemptions(ctx, e.bob, 1, 10) + require.NoError(t, err) + assert.Empty(t, other.Data) + + // The expiry job moves the expired code out of AVAILABLE, once. + expired, err := e.vouchers.ExpireCodes(ctx, e.now) + require.NoError(t, err) + assert.GreaterOrEqual(t, expired, int64(1)) + codes, err := e.vouchers.Codes(ctx, e.orgA, pool.ID, models.VoucherCodeListQuery{}) + require.NoError(t, err) + assert.Equal(t, map[string]int64{"AVAILABLE": 1, "REDEEMED": 1, "EXPIRED": 1}, codes.Counts) + expired, err = e.vouchers.ExpireCodes(ctx, e.now) + require.NoError(t, err) + assert.Zero(t, e.count(`SELECT COUNT(*) FROM voucher_codes WHERE voucher_id = ? AND status = 'AVAILABLE' AND expires_at <= NOW()`, pool.ID)) + _ = expired +} diff --git a/internal/processor/voucher_external_db_test.go b/internal/processor/voucher_external_db_test.go new file mode 100644 index 0000000..08b0b15 --- /dev/null +++ b/internal/processor/voucher_external_db_test.go @@ -0,0 +1,214 @@ +package processor + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" +) + +// fakeVoucherProvider stands in for a provider. issueMode says what Issue does: +// "ok" issues, "reject" refuses, "lost" issues but the answer never arrives, "down" +// never gets the request. lookupMode "down" makes Lookup fail as well. +type fakeVoucherProvider struct { + mu sync.Mutex + issueMode string + lookupMode string + issued map[uuid.UUID]*VoucherIssue + issueCalls int +} + +func (f *fakeVoucherProvider) set(issue, lookup string) { + f.mu.Lock() + defer f.mu.Unlock() + f.issueMode, f.lookupMode = issue, lookup +} + +func (f *fakeVoucherProvider) Issue(_ context.Context, req VoucherIssueRequest) (*VoucherIssue, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.issueCalls++ + switch f.issueMode { + case "reject": + return nil, fmt.Errorf("%w: sold out", ErrVoucherProviderRejected) + case "down": + return nil, context.DeadlineExceeded + } + issue, ok := f.issued[req.RedemptionID] + if !ok { + // The redemption id is the provider's idempotency key. + issue = &VoucherIssue{Code: fmt.Sprintf("EXT-%d", len(f.issued)+1), Ref: "ref-" + req.RedemptionID.String()[:8]} + f.issued[req.RedemptionID] = issue + } + if f.issueMode == "lost" { + return nil, context.DeadlineExceeded + } + return issue, nil +} + +func (f *fakeVoucherProvider) Lookup(_ context.Context, req VoucherIssueRequest) (*VoucherIssue, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.lookupMode == "down" { + return nil, context.DeadlineExceeded + } + if issue, ok := f.issued[req.RedemptionID]; ok { + return issue, nil + } + return nil, ErrVoucherProviderNotFound +} + +func (e *enakGameEnv) redemptionStatus(id uuid.UUID) (string, *string, int) { + e.t.Helper() + var row struct { + Status string + ExternalCode *string + Attempts int + } + require.NoError(e.t, e.db.Raw(`SELECT status, external_code, attempts FROM voucher_redemptions WHERE id = ?`, id).Scan(&row).Error) + return row.Status, row.ExternalCode, row.Attempts +} + +// EG-801, EG-802, EG-803 +func TestVoucherRedemption_External(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + external := e.voucher(e.orgA, models.VoucherInput{Name: "Pulsa", FaceValue: 10_000, PointCost: 1_000, StockMode: "EXTERNAL", + Provider: ptr("acme"), ProviderRef: ptr("PULSA-10K")}) + e.points(e.alice, constants.WalletTxTypeAdjustment, 100_000) + refunds := func(id uuid.UUID) int64 { + return e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'REWARD_REDEEM_REFUND' AND reference_id = ?`, id) + } + // The job works across organizations and other tests may share the database, so + // each step checks its own redemption, not the job's totals. + recover := func() { + t.Helper() + _, _, err := e.redeem.RecoverPending(ctx) + require.NoError(t, err) + } + + catalog, err := e.redeem.Catalog(ctx, e.alice) + require.NoError(t, err) + require.Len(t, catalog, 1, "listed: its provider has an adapter") + assert.Nil(t, catalog[0].Available) + + t.Run("issued at once", func(t *testing.T) { + e.provider.set("ok", "ok") + before := e.pointBalance(e.alice) + res, err := e.redeemVoucher(e.alice, external.ID, "ok") + require.NoError(t, err) + assert.Equal(t, constants.VoucherRedemptionCompleted, res.Status) + require.NotNil(t, res.Code) + assert.Contains(t, *res.Code, "EXT-") + assert.EqualValues(t, before-1_000, res.PointBalance) + assert.Len(t, e.costs(res.ID), 1, "the cost is split once issued") + }) + + t.Run("refused: FAILED and the EnakPoint back", func(t *testing.T) { + e.provider.set("reject", "ok") + before := e.pointBalance(e.alice) + res, err := e.redeemVoucher(e.alice, external.ID, "reject") + require.NoError(t, err) + assert.Equal(t, constants.VoucherRedemptionFailed, res.Status) + assert.Nil(t, res.Code) + assert.EqualValues(t, before, res.PointBalance) + assert.Equal(t, int64(1), refunds(res.ID)) + assert.Empty(t, e.costs(res.ID)) + var reason string + require.NoError(t, e.db.Raw(`SELECT failure_reason FROM voucher_redemptions WHERE id = ?`, res.ID).Scan(&reason).Error) + assert.Contains(t, reason, "sold out") + // The refund lot points back at the lot the EnakPoint came from. + assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM wallet_lots l JOIN wallet_transactions t ON t.id = l.source_transaction_id + WHERE t.type = 'REWARD_REDEEM_REFUND' AND t.reference_id = ? AND l.origin_lot_id IS NOT NULL`, res.ID)) + }) + + t.Run("answer lost: PENDING, then the job finds it issued", func(t *testing.T) { + e.provider.set("lost", "ok") + before := e.pointBalance(e.alice) + res, err := e.redeemVoucher(e.alice, external.ID, "lost") + require.NoError(t, err) + assert.Equal(t, constants.VoucherRedemptionPending, res.Status, "being processed") + assert.Nil(t, res.Code) + assert.EqualValues(t, before-1_000, e.pointBalance(e.alice), "the EnakPoint stay taken meanwhile") + calls := e.provider.issueCalls + again, err := e.redeemVoucher(e.alice, external.ID, "lost") + require.NoError(t, err) + assert.True(t, again.Replayed) + assert.Equal(t, constants.VoucherRedemptionPending, again.Status) + assert.Equal(t, calls, e.provider.issueCalls, "a retry does not call the provider") + + recover() + status, _, _ := e.redemptionStatus(res.ID) + assert.Equal(t, constants.VoucherRedemptionPending, status, "too fresh for the job") + e.now = e.now.Add(3 * time.Minute) + recover() + status, code, _ := e.redemptionStatus(res.ID) + assert.Equal(t, constants.VoucherRedemptionCompleted, status) + assert.Equal(t, *e.provider.issued[res.ID], VoucherIssue{Code: *code, Ref: e.provider.issued[res.ID].Ref}, "the code the provider issued the first time") + assert.Equal(t, calls, e.provider.issueCalls, "found by lookup, not issued again") + assert.Len(t, e.costs(res.ID), 1) + e.now = e.now.Add(3 * time.Minute) + recover() + assert.Len(t, e.costs(res.ID), 1, "settled once") + }) + + t.Run("never reached: the job issues it", func(t *testing.T) { + e.provider.set("down", "ok") + res, err := e.redeemVoucher(e.alice, external.ID, "down") + require.NoError(t, err) + assert.Equal(t, constants.VoucherRedemptionPending, res.Status) + e.provider.set("ok", "ok") + e.now = e.now.Add(3 * time.Minute) + recover() + status, code, _ := e.redemptionStatus(res.ID) + assert.Equal(t, constants.VoucherRedemptionCompleted, status) + assert.NotNil(t, code) + list, err := e.redeem.ListRedemptions(ctx, e.alice, 1, 1) + require.NoError(t, err) + assert.Equal(t, code, list.Data[0].Code, "the customer sees the provider's code") + }) + + t.Run("no answer at all: refunded once after the last attempt", func(t *testing.T) { + e.provider.set("down", "down") + before := e.pointBalance(e.alice) + res, err := e.redeemVoucher(e.alice, external.ID, "silent") + require.NoError(t, err) + for attempt := 2; attempt < voucherMaxAttempts; attempt++ { + e.now = e.now.Add(3 * time.Minute) + recover() + status, _, _ := e.redemptionStatus(res.ID) + assert.Equal(t, constants.VoucherRedemptionPending, status, "attempt %d", attempt) + } + status, _, attempts := e.redemptionStatus(res.ID) + assert.Equal(t, constants.VoucherRedemptionPending, status) + assert.Equal(t, voucherMaxAttempts-1, attempts) + + // Two instances run the last attempt at once: one refund. + e.now = e.now.Add(3 * time.Minute) + var wg sync.WaitGroup + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _, err := e.redeem.RecoverPending(ctx) + assert.NoError(t, err) + }() + } + wg.Wait() + status, _, _ = e.redemptionStatus(res.ID) + assert.Equal(t, constants.VoucherRedemptionFailed, status) + assert.Equal(t, int64(1), refunds(res.ID)) + assert.EqualValues(t, before, e.pointBalance(e.alice)) + e.now = e.now.Add(3 * time.Minute) + recover() + assert.Equal(t, int64(1), refunds(res.ID), "running again refunds nothing more") + }) +} diff --git a/internal/processor/voucher_provider.go b/internal/processor/voucher_provider.go new file mode 100644 index 0000000..3a5dc9e --- /dev/null +++ b/internal/processor/voucher_provider.go @@ -0,0 +1,76 @@ +package processor + +import ( + "context" + "errors" + + "github.com/google/uuid" +) + +var ( + // ErrVoucherProviderRejected is what an adapter wraps when the provider definitely + // will not issue the voucher: out of stock, the product retired, the request + // refused. The redemption fails and the EnakPoint go back. + ErrVoucherProviderRejected = errors.New("voucher provider refused") + // ErrVoucherProviderNotFound is what an adapter's Lookup wraps when the provider + // has no record of the redemption: the issue request never reached it. + ErrVoucherProviderNotFound = errors.New("voucher provider has no such redemption") +) + +// VoucherIssueRequest is one redemption of an EXTERNAL voucher sent to its provider. +type VoucherIssueRequest struct { + // The provider's idempotency key: asking twice for the same redemption must give + // the same voucher, never a second one (docs/rfc-enakgame.md §7.5). + RedemptionID uuid.UUID + // The provider's own reference of the product, from vouchers.provider_ref. + ProviderRef *string + VoucherID uuid.UUID + FaceValue int64 + CustomerID uuid.UUID +} + +// VoucherIssue is what a provider handed out. +type VoucherIssue struct { + Code string + // The provider's reference of this issue, for support and reconciliation. + Ref string +} + +// VoucherProvider issues EXTERNAL vouchers (EG-801). Adapters classify every outcome +// through the error they return: +// +// - nil: issued. +// - wrapping ErrVoucherProviderRejected: definitely not issued, and never will be. +// - wrapping ErrVoucherProviderNotFound (Lookup only): the provider never got it. +// - anything else, a timeout above all: unknown. The redemption stays PENDING and +// the recovery job asks again. +type VoucherProvider interface { + Issue(ctx context.Context, req VoucherIssueRequest) (*VoucherIssue, error) + Lookup(ctx context.Context, req VoucherIssueRequest) (*VoucherIssue, error) +} + +// VoucherProviders are the adapters by the name vouchers.provider holds. +type VoucherProviders map[string]VoucherProvider + +// voucherIssueOutcome is how one call to a provider ended. +type voucherIssueOutcome int + +const ( + voucherIssued voucherIssueOutcome = iota + voucherRejected + voucherUnknown + voucherNotFound +) + +func classifyVoucherIssue(issue *VoucherIssue, err error) voucherIssueOutcome { + switch { + case err == nil && issue != nil && issue.Code != "": + return voucherIssued + case errors.Is(err, ErrVoucherProviderRejected): + return voucherRejected + case errors.Is(err, ErrVoucherProviderNotFound): + return voucherNotFound + } + // An error of any other kind, or a success without a code, settles nothing. + return voucherUnknown +} diff --git a/internal/processor/voucher_provider_test.go b/internal/processor/voucher_provider_test.go new file mode 100644 index 0000000..3ddca95 --- /dev/null +++ b/internal/processor/voucher_provider_test.go @@ -0,0 +1,31 @@ +package processor + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestClassifyVoucherIssue(t *testing.T) { + issued := &VoucherIssue{Code: "ABC"} + for name, tc := range map[string]struct { + issue *VoucherIssue + err error + want voucherIssueOutcome + }{ + "issued": {issued, nil, voucherIssued}, + "issued without code": {&VoucherIssue{}, nil, voucherUnknown}, + "no issue, no error": {nil, nil, voucherUnknown}, + "refused": {nil, fmt.Errorf("acme: %w: sold out", ErrVoucherProviderRejected), voucherRejected}, + "never got it": {nil, fmt.Errorf("acme: %w", ErrVoucherProviderNotFound), voucherNotFound}, + "timeout": {nil, context.DeadlineExceeded, voucherUnknown}, + "server error": {nil, errors.New("acme: 502 bad gateway"), voucherUnknown}, + "code with odd error": {issued, errors.New("acme: partial response"), voucherUnknown}, + "refused despite code": {issued, ErrVoucherProviderRejected, voucherRejected}, + } { + assert.Equal(t, tc.want, classifyVoucherIssue(tc.issue, tc.err), name) + } +} diff --git a/internal/processor/voucher_redemption_external.go b/internal/processor/voucher_redemption_external.go new file mode 100644 index 0000000..ebdba00 --- /dev/null +++ b/internal/processor/voucher_redemption_external.go @@ -0,0 +1,196 @@ +package processor + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/logger" +) + +const ( + // voucherProviderTimeout bounds one call to a provider. A call that runs out is an + // unknown outcome, settled later by the recovery job. + voucherProviderTimeout = 10 * time.Second + // voucherRecoveryAfter is how long a PENDING redemption waits before the recovery + // job asks its provider again. + voucherRecoveryAfter = 2 * time.Minute + // voucherMaxAttempts is how many calls a redemption gets before the job gives up, + // refunds it and marks it FAILED. + voucherMaxAttempts = 10 + // voucherRecoveryBatch is how many PENDING redemptions one run of the job claims. + voucherRecoveryBatch = 50 +) + +// issueExternal asks an EXTERNAL voucher's provider for the voucher of a PENDING +// redemption, outside any transaction, and settles the redemption with the answer +// (docs/rfc-enakgame.md §7.5). With lookup, it first asks whether the provider already +// issued it, and only asks to issue when the provider never got the request. +// +// The call does not stop when the customer's request does: an answer the provider +// gave must not be thrown away. +func (p *VoucherRedemptionProcessor) issueExternal(ctx context.Context, r *entities.VoucherRedemption, v *entities.Voucher, lookup bool) (*entities.VoucherRedemption, error) { + provider, ok := p.providerOf(v) + var issue *VoucherIssue + var err error + if ok { + req := VoucherIssueRequest{RedemptionID: r.ID, ProviderRef: v.ProviderRef, VoucherID: v.ID, FaceValue: r.FaceValue, CustomerID: r.CustomerID} + callCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), voucherProviderTimeout) + if lookup { + issue, err = provider.Lookup(callCtx, req) + } + if !lookup || classifyVoucherIssue(issue, err) == voucherNotFound { + issue, err = provider.Issue(callCtx, req) + } + cancel() + } else { + err = fmt.Errorf("no adapter for provider %q", providerName(v)) + } + outcome := classifyVoucherIssue(issue, err) + if outcome == voucherUnknown || outcome == voucherNotFound { + logger.FromContext(ctx).WithError(err).Warn("Voucher provider gave no answer; the redemption stays PENDING") + } + return p.settleExternal(ctx, r.ID, outcome, issue, err) +} + +// settleExternal applies a provider's answer to a redemption that is still PENDING, +// under its customer's wallet lock: COMPLETED with the voucher and its cost split, or +// FAILED with its EnakPoint refunded. An unknown answer leaves it PENDING, until it +// has had voucherMaxAttempts calls; then it is refunded too. A redemption no longer +// PENDING is left as it is, so settling twice refunds nothing twice. +func (p *VoucherRedemptionProcessor) settleExternal(ctx context.Context, id uuid.UUID, outcome voucherIssueOutcome, issue *VoucherIssue, callErr error) (*entities.VoucherRedemption, error) { + var settled *entities.VoucherRedemption + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + r, err := p.redemptions.GetRedemption(ctx, id) + if err != nil { + return err + } + if err := p.wallet.LockWallet(ctx, r.CustomerID); err != nil { + return err + } + if r, err = p.redemptions.GetRedemption(ctx, id); err != nil { + return err + } + settled = r + if r.Status != constants.VoucherRedemptionPending { + return nil + } + now := p.now() + switch outcome { + case voucherIssued: + code, ref := issue.Code, issue.Ref + var refPtr *string + if ref != "" { + refPtr = &ref + } + moved, err := p.redemptions.MarkCompleted(ctx, r.ID, &code, refPtr, now) + if err != nil || !moved { + return err + } + r.Status, r.ExternalCode, r.ExternalRef, r.CompletedAt = constants.VoucherRedemptionCompleted, &code, refPtr, &now + return p.recordCost(ctx, r) + case voucherRejected: + return p.refundExternal(ctx, r, "the provider refused: "+callErr.Error()) + } + attempts, err := p.redemptions.TouchPending(ctx, r.ID) + if err != nil { + return err + } + r.Attempts = attempts + if attempts >= voucherMaxAttempts { + return p.refundExternal(ctx, r, fmt.Sprintf("the provider did not confirm the voucher after %d attempts", attempts)) + } + return nil + }) + if err != nil { + return nil, err + } + return settled, nil +} + +// refundExternal gives back the EnakPoint of a redemption that will not complete and +// marks it FAILED (§7.5). The refund keeps the expiry of the lots the debit drew from, +// with at least seven days left (§6.3). +func (p *VoucherRedemptionProcessor) refundExternal(ctx context.Context, r *entities.VoucherRedemption, reason string) error { + lots, err := p.wallet.RefundLots(ctx, r.DebitTransactionID) + if err != nil { + return err + } + debitID := r.DebitTransactionID + refund, err := p.wallet.Credit(ctx, WalletCreditInput{ + WalletEntry: WalletEntry{ + CustomerID: r.CustomerID, + Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeRewardRedeemRefund, + Amount: r.PointCost, + ReferenceType: constants.WalletRefTypeRewardRedemption, + ReferenceID: r.ID, + ReversesTransactionID: &debitID, + Description: "Pengembalian EnakPoint voucher", + Metadata: entities.Metadata{"voucher_id": r.VoucherID.String(), "reason": reason}, + IdempotencyKey: "redeem-refund:" + r.ID.String(), + }, + Lots: lots, + }) + if err != nil { + return err + } + moved, err := p.redemptions.MarkFailed(ctx, r.ID, refund.Transaction.ID, reason) + if err != nil { + return err + } + if !moved { + return errors.New("voucher redemption left PENDING during its refund") + } + r.Status, r.RefundTransactionID, r.FailureReason = constants.VoucherRedemptionFailed, &refund.Transaction.ID, &reason + return nil +} + +// RecoverPending is the recovery job (§7.5 step 4): it asks the provider again about +// every redemption PENDING for longer than voucherRecoveryAfter, and settles it. It +// returns how many it completed and how many it failed. +func (p *VoucherRedemptionProcessor) RecoverPending(ctx context.Context) (completed, failed int, err error) { + claimed, err := p.redemptions.ClaimStalePending(ctx, p.now().Add(-voucherRecoveryAfter), voucherRecoveryBatch) + if err != nil { + return 0, 0, err + } + var failures []error + for i := range claimed { + r := &claimed[i] + v, err := p.vouchers.GetVoucher(ctx, r.OrganizationID, r.VoucherID) + if err == nil { + r, err = p.issueExternal(ctx, r, v, true) + } + if err != nil { + failures = append(failures, fmt.Errorf("redemption %s: %w", claimed[i].ID, err)) + continue + } + switch r.Status { + case constants.VoucherRedemptionCompleted: + completed++ + case constants.VoucherRedemptionFailed: + failed++ + } + } + return completed, failed, errors.Join(failures...) +} + +func (p *VoucherRedemptionProcessor) providerOf(v *entities.Voucher) (VoucherProvider, bool) { + if v.Provider == nil { + return nil, false + } + provider, ok := p.providers[*v.Provider] + return provider, ok +} + +func providerName(v *entities.Voucher) string { + if v.Provider == nil { + return "" + } + return *v.Provider +} diff --git a/internal/processor/voucher_redemption_processor.go b/internal/processor/voucher_redemption_processor.go new file mode 100644 index 0000000..b5f63a0 --- /dev/null +++ b/internal/processor/voucher_redemption_processor.go @@ -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, + } +} diff --git a/internal/processor/wallet_enakgame_types_db_test.go b/internal/processor/wallet_enakgame_types_db_test.go new file mode 100644 index 0000000..60fa368 --- /dev/null +++ b/internal/processor/wallet_enakgame_types_db_test.go @@ -0,0 +1,133 @@ +package processor + +import ( + "context" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/repository" +) + +// The EnakGame ledger types (docs/rfc-enakgame.md §6) pass both the engine and the +// wallet_transactions constraints, and the constraints reject what the engine would. +// Needs TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go. +func TestWalletProcessor_EnakGameTypesAgainstPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + + org, c := uuid.New(), uuid.New() + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame ledger test', 'basic')`, org).Error) + require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'C')`, c, org).Error) + t.Cleanup(func() { + db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id = ?)`, c) + db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, c) + db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, c) + db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, c) + db.Exec(`DELETE FROM customers WHERE id = ?`, c) + db.Exec(`DELETE FROM organizations WHERE id = ?`, org) + }) + + p := NewWalletProcessor(repository.NewWalletRepository(db)) + txm := repository.NewTxManager(db) + now := time.Now() + session, redemption := uuid.New(), uuid.New() + entry := func(currency, txType, refType string, refID uuid.UUID, amount int64) WalletEntry { + return WalletEntry{ + CustomerID: c, Currency: currency, Type: txType, Amount: amount, + ReferenceType: refType, ReferenceID: refID, Description: txType, + IdempotencyKey: txType + ":" + refID.String(), + } + } + // refundLots is the §6.3 refund: one lot per lot the debit drew from, pointing back + // at it, with at least the refund grace period left. + refundLots := func(debit *WalletResult) []WalletLotInput { + lots := debit.CarryOver() + for i := range lots { + lots[i].ExpiresAt = RefundExpiry(lots[i].ExpiresAt, now) + } + return lots + } + + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + soon := now.Add(time.Hour) + _, err := p.Credit(ctx, WalletCreditInput{ + WalletEntry: entry(constants.WalletCurrencyCoin, constants.WalletTxTypeGameReward, constants.WalletRefTypeGameSession, session, 30), + Lots: []WalletLotInput{{Amount: 30, ExpiresAt: &soon}}, + }) + require.NoError(t, err) + + spend, err := p.Debit(ctx, WalletDebitInput{WalletEntry: entry(constants.WalletCurrencyCoin, constants.WalletTxTypeGameSpend, constants.WalletRefTypeGameSession, session, 10)}) + require.NoError(t, err) + refund := entry(constants.WalletCurrencyCoin, constants.WalletTxTypeGameSpendRefund, constants.WalletRefTypeGameSession, session, 10) + refund.ReversesTransactionID = &spend.Transaction.ID + refunded, err := p.Credit(ctx, WalletCreditInput{WalletEntry: refund, Lots: refundLots(spend)}) + require.NoError(t, err) + require.Len(t, refunded.Lots, 1) + assert.Equal(t, spend.Allocations[0].LotID, *refunded.Lots[0].OriginLotID) + assert.True(t, refunded.Lots[0].ExpiresAt.After(soon), "the refund outlives the lot it came from") + + adjust := entry(constants.WalletCurrencyPoint, constants.WalletTxTypeAdjustment, constants.WalletRefTypeUser, uuid.New(), 100) + adjust.CreatedByUser, adjust.Reason = ptr(uuid.New()), ptr("test") + _, err = p.Credit(ctx, WalletCreditInput{WalletEntry: adjust}) + require.NoError(t, err) + redeemed, err := p.Debit(ctx, WalletDebitInput{WalletEntry: entry(constants.WalletCurrencyPoint, constants.WalletTxTypeRewardRedeem, constants.WalletRefTypeRewardRedemption, redemption, 40)}) + require.NoError(t, err) + back := entry(constants.WalletCurrencyPoint, constants.WalletTxTypeRewardRedeemRefund, constants.WalletRefTypeRewardRedemption, redemption, 40) + back.ReversesTransactionID = &redeemed.Transaction.ID + _, err = p.Credit(ctx, WalletCreditInput{WalletEntry: back, Lots: refundLots(redeemed)}) + return err + })) + + var types []string + require.NoError(t, db.Raw(`SELECT type FROM wallet_transactions WHERE customer_id = ? ORDER BY created_at, type`, c).Scan(&types).Error) + for _, want := range []string{ + constants.WalletTxTypeGameReward, constants.WalletTxTypeGameSpend, constants.WalletTxTypeGameSpendRefund, + constants.WalletTxTypeRewardRedeem, constants.WalletTxTypeRewardRedeemRefund, + } { + assert.Contains(t, types, want) + } + discrepancies, err := repository.NewWalletReconciliationRepository(db).FindDiscrepancies(context.Background(), 1000) + require.NoError(t, err) + for _, d := range discrepancies { + assert.NotEqual(t, c, d.CustomerID, "reconciliation: %+v", d) + } + + // What the constraints reject, inserted directly so the engine is not in the way. + var sourceIDs []string + require.NoError(t, db.Raw(`SELECT id::text FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, c).Scan(&sourceIDs).Error) + require.Len(t, sourceIDs, 1) + source := uuid.MustParse(sourceIDs[0]) + rejected := map[string]struct { + currency, txType string + reverses *uuid.UUID + }{ + "GAME_REWARD in POINT": {constants.WalletCurrencyPoint, constants.WalletTxTypeGameReward, nil}, + "GAME_SPEND_REFUND in POINT": {constants.WalletCurrencyPoint, constants.WalletTxTypeGameSpendRefund, &source}, + "GAME_SPEND_REFUND without source": {constants.WalletCurrencyCoin, constants.WalletTxTypeGameSpendRefund, nil}, + "REWARD_REDEEM_REFUND in COIN": {constants.WalletCurrencyCoin, constants.WalletTxTypeRewardRedeemRefund, &source}, + "REWARD_REDEEM_REFUND without source": {constants.WalletCurrencyPoint, constants.WalletTxTypeRewardRedeemRefund, nil}, + } + for name, row := range rejected { + t.Run(name, func(t *testing.T) { + err := db.Exec(` + INSERT INTO wallet_transactions (organization_id, customer_id, currency, type, amount, balance_after, + reference_type, reference_id, reverses_transaction_id, description) + VALUES (?, ?, ?, ?, 1, 0, 'GAME_SESSION', ?, ?, 'x')`, + org, c, row.currency, row.txType, uuid.New(), row.reverses).Error + assert.ErrorContains(t, err, "violates check constraint") + }) + } +} diff --git a/internal/processor/wallet_exchange_processor_test.go b/internal/processor/wallet_exchange_processor_test.go index a6d64c5..030f021 100644 --- a/internal/processor/wallet_exchange_processor_test.go +++ b/internal/processor/wallet_exchange_processor_test.go @@ -3,6 +3,7 @@ package processor import ( "context" "errors" + "sync" "testing" "time" @@ -127,15 +128,19 @@ func (f *walletMoveRepoFake) TransferredOutSince(_ context.Context, customerID u return total, nil } -// movePinFake accepts one PIN and records the actions it was asked to approve. +// movePinFake accepts one PIN and records the actions it was asked to approve. Tests +// call it from several goroutines at once. type movePinFake struct { good string err error + mu sync.Mutex actions []PinAction } func (f *movePinFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error { + f.mu.Lock() f.actions = append(f.actions, action) + f.mu.Unlock() if f.err != nil { return f.err } diff --git a/internal/processor/wallet_processor.go b/internal/processor/wallet_processor.go index bad8d41..06c66a8 100644 --- a/internal/processor/wallet_processor.go +++ b/internal/processor/wallet_processor.go @@ -149,6 +149,38 @@ func (p *WalletProcessor) FindTransaction(ctx context.Context, idempotencyKey st return p.repo.GetTransactionByIdempotencyKey(ctx, idempotencyKey) } +// RefundLots turns the allocations of a debit back into lots for its refund +// (docs/rfc-enakgame.md §6.3): one per lot the debit drew from, pointing back at it, +// expiring when that lot did but at least seven days from now (RefundExpiry). +func (p *WalletProcessor) RefundLots(ctx context.Context, debitTransactionID uuid.UUID) ([]WalletLotInput, error) { + allocations, err := p.repo.ListAllocationsByTransaction(ctx, debitTransactionID) + if err != nil { + return nil, fmt.Errorf("failed to read the debit's allocations: %w", err) + } + ids := make([]uuid.UUID, 0, len(allocations)) + for _, a := range allocations { + ids = append(ids, a.LotID) + } + lots, err := p.repo.GetLotsByIDs(ctx, ids) + if err != nil { + return nil, fmt.Errorf("failed to read the debit's lots: %w", err) + } + expiries := make(map[uuid.UUID]*time.Time, len(lots)) + for _, lot := range lots { + expiries[lot.ID] = lot.ExpiresAt + } + out := make([]WalletLotInput, 0, len(allocations)) + for _, a := range allocations { + expiresAt, ok := expiries[a.LotID] + if !ok { + return nil, fmt.Errorf("lot %s of the debit is missing", a.LotID) + } + lotID := a.LotID + out = append(out, WalletLotInput{Amount: a.Amount, ExpiresAt: RefundExpiry(expiresAt, p.now()), OriginLotID: &lotID}) + } + return out, nil +} + // ExpireLot takes what is left in a lot whose expiry has passed at asOf, as an EXPIRE // row pointing at the lot (F12). It locks the wallet before reading the lot again, so // it never races a payment for the same balance, and the key expire:{lot_id} makes a @@ -489,11 +521,15 @@ var walletTypeRules = map[string]walletTypeRule{ constants.WalletTxTypeExchangeIn: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true}, constants.WalletTxTypeTransferOut: {debit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true}, constants.WalletTxTypeTransferIn: {credit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true}, - constants.WalletTxTypeGameSpend: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGamePlay}}, - constants.WalletTxTypeExpire: {debit: true, referenceTypes: []string{constants.WalletRefTypeLot}}, - constants.WalletTxTypeAdjustment: {credit: true, debit: true, referenceTypes: []string{constants.WalletRefTypeUser}, needsActor: true}, - constants.WalletTxTypeMigration: {credit: true, referenceTypes: []string{constants.WalletRefTypeLegacyPoints, constants.WalletRefTypeLegacyTokens}}, - constants.WalletTxTypeRewardRedeem: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}}, + // GAME_PLAY for the old spin, GAME_SESSION for EnakGame. + constants.WalletTxTypeGameSpend: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGamePlay, constants.WalletRefTypeGameSession}}, + constants.WalletTxTypeGameSpendRefund: {credit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGameSession}, needsReverses: true}, + constants.WalletTxTypeGameReward: {credit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGameSession}}, + constants.WalletTxTypeExpire: {debit: true, referenceTypes: []string{constants.WalletRefTypeLot}}, + constants.WalletTxTypeAdjustment: {credit: true, debit: true, referenceTypes: []string{constants.WalletRefTypeUser}, needsActor: true}, + constants.WalletTxTypeMigration: {credit: true, referenceTypes: []string{constants.WalletRefTypeLegacyPoints, constants.WalletRefTypeLegacyTokens}}, + constants.WalletTxTypeRewardRedeem: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}}, + constants.WalletTxTypeRewardRedeemRefund: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}, needsReverses: true}, } func validateWalletEntry(in *WalletEntry, credit bool) error { diff --git a/internal/processor/wallet_processor_test.go b/internal/processor/wallet_processor_test.go index 0b54461..852a667 100644 --- a/internal/processor/wallet_processor_test.go +++ b/internal/processor/wallet_processor_test.go @@ -721,6 +721,46 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) { in.Type = constants.WalletTxTypeMigration in.ReferenceType = constants.WalletRefTypeOrder }, + "GAME_REWARD in POINT": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeGameReward + in.ReferenceType = constants.WalletRefTypeGameSession + }, + "GAME_REWARD wrong reference": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeGameReward + in.Currency = constants.WalletCurrencyCoin + in.ReferenceType = constants.WalletRefTypeGamePlay + }, + "GAME_SPEND_REFUND in POINT": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeGameSpendRefund + in.ReferenceType = constants.WalletRefTypeGameSession + in.ReversesTransactionID = ptr(uuid.New()) + }, + "GAME_SPEND_REFUND wrong reference": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeGameSpendRefund + in.Currency = constants.WalletCurrencyCoin + in.ReferenceType = constants.WalletRefTypeGamePlay + in.ReversesTransactionID = ptr(uuid.New()) + }, + "GAME_SPEND_REFUND without source": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeGameSpendRefund + in.Currency = constants.WalletCurrencyCoin + in.ReferenceType = constants.WalletRefTypeGameSession + }, + "REWARD_REDEEM_REFUND in COIN": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeRewardRedeemRefund + in.Currency = constants.WalletCurrencyCoin + in.ReferenceType = constants.WalletRefTypeRewardRedemption + in.ReversesTransactionID = ptr(uuid.New()) + }, + "REWARD_REDEEM_REFUND wrong reference": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeRewardRedeemRefund + in.ReferenceType = constants.WalletRefTypeGameSession + in.ReversesTransactionID = ptr(uuid.New()) + }, + "REWARD_REDEEM_REFUND without source": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeRewardRedeemRefund + in.ReferenceType = constants.WalletRefTypeRewardRedemption + }, } for name, mutate := range credits { t.Run("credit/"+name, func(t *testing.T) { @@ -762,6 +802,27 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) { in.Type = constants.WalletTxTypeRewardRedeem in.ReferenceType = constants.WalletRefTypeOrder }, + "GAME_SPEND wrong reference": func(in *WalletDebitInput) { + in.Type = constants.WalletTxTypeGameSpend + in.Currency = constants.WalletCurrencyCoin + in.ReferenceType = constants.WalletRefTypeRewardRedemption + }, + "GAME_REWARD as debit": func(in *WalletDebitInput) { + in.Type = constants.WalletTxTypeGameReward + in.Currency = constants.WalletCurrencyCoin + in.ReferenceType = constants.WalletRefTypeGameSession + }, + "GAME_SPEND_REFUND as debit": func(in *WalletDebitInput) { + in.Type = constants.WalletTxTypeGameSpendRefund + in.Currency = constants.WalletCurrencyCoin + in.ReferenceType = constants.WalletRefTypeGameSession + in.ReversesTransactionID = ptr(uuid.New()) + }, + "REWARD_REDEEM_REFUND as debit": func(in *WalletDebitInput) { + in.Type = constants.WalletTxTypeRewardRedeemRefund + in.ReferenceType = constants.WalletRefTypeRewardRedemption + in.ReversesTransactionID = ptr(uuid.New()) + }, } for name, mutate := range debits { t.Run("debit/"+name, func(t *testing.T) { @@ -777,6 +838,39 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) { } } +func TestWalletProcessor_AcceptsEnakGameTypes(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + coin := func(txType, refType string, amount int64) WalletEntry { + return WalletEntry{ + CustomerID: c, Currency: constants.WalletCurrencyCoin, Type: txType, Amount: amount, + ReferenceType: refType, ReferenceID: uuid.New(), Description: txType, + } + } + + e.credit(t, WalletCreditInput{WalletEntry: coin(constants.WalletTxTypeGameReward, constants.WalletRefTypeGameSession, 30)}) + spend, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: coin(constants.WalletTxTypeGameSpend, constants.WalletRefTypeGameSession, 10)}) + require.NoError(t, err) + _, err = e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: coin(constants.WalletTxTypeGameSpend, constants.WalletRefTypeGamePlay, 5)}) + require.NoError(t, err, "the old spin still references GAME_PLAY") + refund := coin(constants.WalletTxTypeGameSpendRefund, constants.WalletRefTypeGameSession, 10) + refund.ReversesTransactionID = &spend.Transaction.ID + e.credit(t, WalletCreditInput{WalletEntry: refund, Lots: spend.CarryOver()}) + + e.credit(t, earn(c, 100, nil)) + redeemed, err := e.p.Debit(e.ctx, redeem(c, 40)) + require.NoError(t, err) + back := redeem(c, 40).WalletEntry + back.Type = constants.WalletTxTypeRewardRedeemRefund + back.ReversesTransactionID = &redeemed.Transaction.ID + e.credit(t, WalletCreditInput{WalletEntry: back, Lots: redeemed.CarryOver()}) + + wallet, err := e.repo.GetWallet(e.ctx, c) + require.NoError(t, err) + assert.EqualValues(t, 25, wallet.CoinBalance) + assert.EqualValues(t, 100, wallet.PointBalance) +} + func TestWalletProcessor_UnknownCustomer(t *testing.T) { e := newWalletTestEnv(t) _, err := e.p.Credit(e.ctx, earn(uuid.New(), 10, nil)) diff --git a/internal/repository/audit_log_repository.go b/internal/repository/audit_log_repository.go new file mode 100644 index 0000000..8a2ac77 --- /dev/null +++ b/internal/repository/audit_log_repository.go @@ -0,0 +1,124 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + "apskel-pos-be/internal/entities" +) + +// ErrAuditTxRequired is returned when an audit row is written outside a transaction: +// the row must commit or roll back together with the change it records. +var ErrAuditTxRequired = errors.New("audit: write must run inside a transaction") + +// AuditLogRepository stores audit_logs (docs/rfc-enakgame.md §5.9). +type AuditLogRepository interface { + // Insert writes a row in the caller's transaction, or returns ErrAuditTxRequired. + Insert(ctx context.Context, entry *entities.AuditLog) error + // ListByEntity returns an entity's rows in an organization, newest first. + ListByEntity(ctx context.Context, organizationID uuid.UUID, entityType string, entityID uuid.UUID, limit int) ([]entities.AuditLog, error) +} + +type auditLogRepository struct { + db *gorm.DB +} + +func NewAuditLogRepository(db *gorm.DB) AuditLogRepository { + return &auditLogRepository{db: db} +} + +func (r *auditLogRepository) Insert(ctx context.Context, entry *entities.AuditLog) error { + tx, ok := ctx.Value(txKey).(*gorm.DB) + if !ok || tx == nil { + return ErrAuditTxRequired + } + if entry.ID == uuid.Nil { + entry.ID = uuid.New() + } + var rows []struct{ CreatedAt time.Time } + err := tx.WithContext(ctx).Raw(` + INSERT INTO audit_logs (id, organization_id, actor_type, actor_id, entity_type, entity_id, + action, before, after, reason, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?, ?) + RETURNING created_at`, + entry.ID, entry.OrganizationID, entry.ActorType, entry.ActorID, entry.EntityType, entry.EntityID, + entry.Action, jsonOrNull(entry.Before), jsonOrNull(entry.After), entry.Reason, entry.Source). + Scan(&rows).Error + if err != nil { + return fmt.Errorf("failed to write audit log: %w", err) + } + if len(rows) == 1 { + entry.CreatedAt = rows[0].CreatedAt + } + return nil +} + +func (r *auditLogRepository) ListByEntity(ctx context.Context, organizationID uuid.UUID, entityType string, entityID uuid.UUID, limit int) ([]entities.AuditLog, error) { + var rows []struct { + ID string + OrganizationID string + ActorType string + ActorID *string + EntityType string + EntityID string + Action string + Before *string + After *string + Reason *string + Source string + CreatedAt time.Time + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT id::text AS id, organization_id::text AS organization_id, actor_type, actor_id::text AS actor_id, + entity_type, entity_id::text AS entity_id, action, before::text AS before, after::text AS after, + reason, source, created_at + FROM audit_logs + WHERE organization_id = ? AND entity_type = ? AND entity_id = ? + ORDER BY created_at DESC, id + LIMIT ?`, organizationID, entityType, entityID, limit).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to read audit logs: %w", err) + } + out := make([]entities.AuditLog, 0, len(rows)) + for _, row := range rows { + entry := entities.AuditLog{ + ActorType: row.ActorType, + EntityType: row.EntityType, + Action: row.Action, + Reason: row.Reason, + Source: row.Source, + CreatedAt: row.CreatedAt, + } + entry.ID, _ = uuid.Parse(row.ID) + entry.OrganizationID, _ = uuid.Parse(row.OrganizationID) + entry.EntityID, _ = uuid.Parse(row.EntityID) + if row.ActorID != nil { + if id, err := uuid.Parse(*row.ActorID); err == nil { + entry.ActorID = &id + } + } + if row.Before != nil { + entry.Before = json.RawMessage(*row.Before) + } + if row.After != nil { + entry.After = json.RawMessage(*row.After) + } + out = append(out, entry) + } + return out, nil +} + +// jsonOrNull passes a JSON document as text, or NULL when there is none. +func jsonOrNull(doc json.RawMessage) *string { + if len(doc) == 0 { + return nil + } + s := string(doc) + return &s +} diff --git a/internal/repository/enakgame_repository.go b/internal/repository/enakgame_repository.go new file mode 100644 index 0000000..9c30ef2 --- /dev/null +++ b/internal/repository/enakgame_repository.go @@ -0,0 +1,237 @@ +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" +) + +var ( + // ErrEnakGameNotFound means no game with that id in the organization, or, for a + // write, none that may still change: an archived game never does. + ErrEnakGameNotFound = errors.New("enakgame: game not found") + // ErrGameRewardConfigNotFound means no reward configuration with that id in the + // organization, or no active one for the game. + ErrGameRewardConfigNotFound = errors.New("enakgame: reward config not found") + // ErrEnakGameSlugTaken means another game of the organization has the slug. + ErrEnakGameSlugTaken = errors.New("enakgame: slug already used") +) + +// EnakGameFilter selects an organization's games. +type EnakGameFilter struct { + OrganizationID uuid.UUID + // Empty for every status. + Statuses []string + // Matched against the name, case-insensitively. + Search string + Offset int + Limit int +} + +// EnakGameRepository stores EnakGame games and their reward configurations +// (docs/rfc-enakgame.md §5.1, §5.2). Every read and write is scoped to an +// organization (D8), so the old games, which have none, never show up. +type EnakGameRepository interface { + CreateGame(ctx context.Context, game *entities.Game) error + GetGame(ctx context.Context, organizationID, id uuid.UUID) (*entities.Game, error) + // LockGame is GetGame with the row locked until the transaction ends. + LockGame(ctx context.Context, organizationID, id uuid.UUID) (*entities.Game, error) + // ListGames returns a page of games, newest first, and the total. + ListGames(ctx context.Context, filter EnakGameFilter) ([]entities.Game, int64, error) + // UpdateGame stores the game's editable columns, everything but its organization + // and status. + UpdateGame(ctx context.Context, game *entities.Game) error + SetGameStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error + + // CreateRewardConfig stores a configuration as the game's next version and sets + // config.Version. + CreateRewardConfig(ctx context.Context, config *entities.GameRewardConfig) error + GetRewardConfig(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameRewardConfig, error) + GetActiveRewardConfig(ctx context.Context, organizationID, gameID uuid.UUID) (*entities.GameRewardConfig, error) + // ListRewardConfigs returns every version of a game's configuration, newest first. + ListRewardConfigs(ctx context.Context, organizationID, gameID uuid.UUID) ([]entities.GameRewardConfig, error) + // SetRewardConfigStatus moves a configuration from one status to another, the only + // change a configuration allows (D7). It reports false when the configuration was + // not in from. + SetRewardConfigStatus(ctx context.Context, organizationID, id uuid.UUID, from, to string) (bool, error) +} + +type enakGameRepository struct { + db *gorm.DB +} + +func NewEnakGameRepository(db *gorm.DB) EnakGameRepository { + return &enakGameRepository{db: db} +} + +func (r *enakGameRepository) CreateGame(ctx context.Context, game *entities.Game) error { + if err := DBFromContext(ctx, r.db).WithContext(ctx).Omit(clause.Associations).Create(game).Error; err != nil { + if isUniqueViolation(err, "uq_games_org_slug") { + return ErrEnakGameSlugTaken + } + return fmt.Errorf("failed to create game: %w", err) + } + return nil +} + +func (r *enakGameRepository) getGame(ctx context.Context, organizationID, id uuid.UUID, lock bool) (*entities.Game, 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 game entities.Game + if err := q.First(&game).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrEnakGameNotFound + } + return nil, fmt.Errorf("failed to read game: %w", err) + } + return &game, nil +} + +func (r *enakGameRepository) GetGame(ctx context.Context, organizationID, id uuid.UUID) (*entities.Game, error) { + return r.getGame(ctx, organizationID, id, false) +} + +func (r *enakGameRepository) LockGame(ctx context.Context, organizationID, id uuid.UUID) (*entities.Game, error) { + return r.getGame(ctx, organizationID, id, true) +} + +func (r *enakGameRepository) ListGames(ctx context.Context, filter EnakGameFilter) ([]entities.Game, int64, error) { + q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.Game{}).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 games: %w", err) + } + var games []entities.Game + if err := q.Order("created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&games).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list games: %w", err) + } + return games, total, nil +} + +func (r *enakGameRepository) UpdateGame(ctx context.Context, game *entities.Game) error { + if game.OrganizationID == nil { + return ErrEnakGameNotFound + } + result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` + UPDATE games SET name = ?, type = ?, slug = ?, description = ?, thumbnail_url = ?, game_url = ?, + version = ?, entry_cost = ?, session_ttl_seconds = ?, result_rules = ?, updated_at = NOW() + WHERE organization_id = ? AND id = ? AND status <> ?`, + game.Name, game.Type, game.Slug, game.Description, game.ThumbnailURL, game.GameURL, + game.Version, game.EntryCost, game.SessionTTLSeconds, game.ResultRules, + *game.OrganizationID, game.ID, constants.GameStatusArchived) + if result.Error != nil { + if isUniqueViolation(result.Error, "uq_games_org_slug") { + return ErrEnakGameSlugTaken + } + return fmt.Errorf("failed to update game: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrEnakGameNotFound + } + return nil +} + +func (r *enakGameRepository) SetGameStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error { + result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` + UPDATE games SET status = ?, updated_at = NOW() + WHERE organization_id = ? AND id = ? AND status <> ?`, + status, organizationID, id, constants.GameStatusArchived) + if result.Error != nil { + return fmt.Errorf("failed to change game status: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrEnakGameNotFound + } + return nil +} + +func (r *enakGameRepository) CreateRewardConfig(ctx context.Context, config *entities.GameRewardConfig) error { + if config.ID == uuid.Nil { + config.ID = uuid.New() + } + if config.Status == "" { + config.Status = constants.GameRewardConfigStatusDraft + } + // The version is computed in the insert. Two inserts at once may compute the same + // one, and the unique (game_id, version) refuses the second. + var rows []struct { + Version int + CreatedAt time.Time + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + INSERT INTO game_reward_configs (id, organization_id, game_id, version, reward_type, rules, + max_reward, status, effective_at, created_by, reason) + SELECT ?, g.organization_id, g.id, + COALESCE((SELECT MAX(version) FROM game_reward_configs WHERE game_id = g.id), 0) + 1, + ?, ?::jsonb, ?, ?, ?, ?, ? + FROM games g WHERE g.organization_id = ? AND g.id = ? + RETURNING version, created_at`, + config.ID, config.RewardType, config.Rules, config.MaxReward, config.Status, config.EffectiveAt, + config.CreatedBy, config.Reason, config.OrganizationID, config.GameID).Scan(&rows).Error + if err != nil { + return fmt.Errorf("failed to create reward config: %w", err) + } + if len(rows) == 0 { + return ErrEnakGameNotFound + } + config.Version, config.CreatedAt = rows[0].Version, rows[0].CreatedAt + return nil +} + +func (r *enakGameRepository) firstRewardConfig(q *gorm.DB) (*entities.GameRewardConfig, error) { + var config entities.GameRewardConfig + if err := q.First(&config).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGameRewardConfigNotFound + } + return nil, fmt.Errorf("failed to read reward config: %w", err) + } + return &config, nil +} + +func (r *enakGameRepository) GetRewardConfig(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameRewardConfig, error) { + return r.firstRewardConfig(DBFromContext(ctx, r.db).WithContext(ctx). + Where("organization_id = ? AND id = ?", organizationID, id)) +} + +func (r *enakGameRepository) GetActiveRewardConfig(ctx context.Context, organizationID, gameID uuid.UUID) (*entities.GameRewardConfig, error) { + return r.firstRewardConfig(DBFromContext(ctx, r.db).WithContext(ctx). + Where("organization_id = ? AND game_id = ? AND status = ?", organizationID, gameID, constants.GameRewardConfigStatusActive)) +} + +func (r *enakGameRepository) ListRewardConfigs(ctx context.Context, organizationID, gameID uuid.UUID) ([]entities.GameRewardConfig, error) { + var configs []entities.GameRewardConfig + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("organization_id = ? AND game_id = ?", organizationID, gameID). + Order("version DESC").Find(&configs).Error + if err != nil { + return nil, fmt.Errorf("failed to list reward configs: %w", err) + } + return configs, nil +} + +func (r *enakGameRepository) SetRewardConfigStatus(ctx context.Context, organizationID, id uuid.UUID, from, to string) (bool, error) { + result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` + UPDATE game_reward_configs SET status = ? WHERE organization_id = ? AND id = ? AND status = ?`, + to, organizationID, id, from) + if result.Error != nil { + return false, fmt.Errorf("failed to change reward config status: %w", result.Error) + } + return result.RowsAffected == 1, nil +} diff --git a/internal/repository/enakgame_repository_test.go b/internal/repository/enakgame_repository_test.go new file mode 100644 index 0000000..8205dd9 --- /dev/null +++ b/internal/repository/enakgame_repository_test.go @@ -0,0 +1,471 @@ +package repository + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" +) + +// EnakGame tables against Postgres (docs/rfc-enakgame.md §5): what the constraints +// refuse, and what the repositories do. Needs TEST_DATABASE_URL; see +// wallet_repository_test.go. + +type enakGameFixture struct { + db *gorm.DB + games EnakGameRepository + sessions GameSessionRepository + budgets GameBudgetRepository + org uuid.UUID + other uuid.UUID + customer uuid.UUID + admin uuid.UUID +} + +func newEnakGameFixture(t *testing.T) *enakGameFixture { + t.Helper() + db := walletTestDB(t) + f := &enakGameFixture{ + db: db, games: NewEnakGameRepository(db), sessions: NewGameSessionRepository(db), budgets: NewGameBudgetRepository(db), + org: uuid.New(), other: uuid.New(), customer: uuid.New(), admin: uuid.New(), + } + orgs := []uuid.UUID{f.org, f.other} + for _, org := range orgs { + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame test', 'basic')`, org).Error) + } + require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'enakgame test')`, f.customer, f.org).Error) + t.Cleanup(func() { + for _, q := range []string{ + `DELETE FROM game_session_rewards WHERE session_id IN (SELECT id FROM game_sessions WHERE organization_id IN ?)`, + `DELETE FROM game_sessions WHERE organization_id IN ?`, + `DELETE FROM game_reward_configs WHERE organization_id IN ?`, + `DELETE FROM game_budgets WHERE organization_id IN ?`, + `DELETE FROM games WHERE organization_id IN ?`, + `DELETE FROM wallet_transactions WHERE organization_id IN ?`, + `DELETE FROM customers WHERE organization_id IN ?`, + `DELETE FROM organizations WHERE id IN ?`, + } { + db.Exec(q, orgs) + } + }) + return f +} + +func (f *enakGameFixture) game(t *testing.T, org uuid.UUID, slug string) *entities.Game { + t.Helper() + game := &entities.Game{ + Name: "Tap " + slug, Type: entities.GameTypeMinigame, OrganizationID: &org, Slug: &slug, + Status: constants.GameStatusActive, EntryCost: 5, SessionTTLSeconds: 600, + } + require.NoError(t, f.games.CreateGame(context.Background(), game)) + return game +} + +func (f *enakGameFixture) config(t *testing.T, game *entities.Game) *entities.GameRewardConfig { + t.Helper() + config := &entities.GameRewardConfig{ + OrganizationID: *game.OrganizationID, GameID: game.ID, RewardType: constants.GameRewardTypeFixed, + Rules: entities.JSONDocument(`{"amount": 10}`), MaxReward: 10, CreatedBy: f.admin, + } + require.NoError(t, f.games.CreateRewardConfig(context.Background(), config)) + return config +} + +// walletTx writes a bare ledger row for the session foreign keys to point at. +// reverses is the row a refund reverses, nil otherwise. +func (f *enakGameFixture) walletTx(t *testing.T, txType string, amount int64, reverses *uuid.UUID) uuid.UUID { + t.Helper() + id := uuid.New() + require.NoError(t, f.db.Exec(` + INSERT INTO wallet_transactions (id, organization_id, customer_id, currency, type, amount, balance_after, + reference_type, reference_id, reverses_transaction_id, description) + VALUES (?, ?, ?, 'COIN', ?, ?, 0, 'GAME_SESSION', ?, ?, 'test')`, + id, f.org, f.customer, txType, amount, uuid.New(), reverses).Error) + return id +} + +func (f *enakGameFixture) session(t *testing.T, game *entities.Game, config *entities.GameRewardConfig, expiresAt time.Time) *entities.GameSession { + t.Helper() + session := &entities.GameSession{ + OrganizationID: f.org, CustomerID: f.customer, GameID: game.ID, RewardConfigID: config.ID, + EntryCost: game.EntryCost, ExpiresAt: expiresAt, SpendTransactionID: f.walletTx(t, constants.WalletTxTypeGameSpend, -game.EntryCost, nil), + } + require.NoError(t, f.sessions.CreateSession(context.Background(), session)) + return session +} + +func (f *enakGameFixture) budget(t *testing.T, org uuid.UUID, scope string, start, end time.Time) *entities.GameBudget { + t.Helper() + budget := &entities.GameBudget{ + OrganizationID: org, Scope: scope, Name: "Oktober", PeriodStart: start, PeriodEnd: end, Amount: 100_000_000, CreatedBy: f.admin, + } + require.NoError(t, f.budgets.CreateBudget(context.Background(), budget)) + return budget +} + +func date(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) } + +func TestEnakGameSchema_RejectsWhatTheRFCForbids(t *testing.T) { + f := newEnakGameFixture(t) + game := f.game(t, f.org, "tap") + config := f.config(t, game) + ctx := context.Background() + rejected := func(t *testing.T, err error, constraint string) { + t.Helper() + require.Error(t, err) + assert.Contains(t, err.Error(), constraint) + } + + t.Run("ACTIVE game without organization", func(t *testing.T) { + rejected(t, f.db.Exec(`INSERT INTO games (name, type, slug, entry_cost) VALUES ('x', 'MINIGAME', 'x', 1)`).Error, "chk_games_enakgame_identity") + }) + t.Run("ACTIVE game without slug", func(t *testing.T) { + rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, entry_cost) VALUES ('x', 'MINIGAME', ?, 1)`, f.org).Error, "chk_games_enakgame_identity") + }) + t.Run("entry cost 0", func(t *testing.T) { + rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, slug, entry_cost) VALUES ('x', 'MINIGAME', ?, 'free', 0)`, f.org).Error, "chk_games_entry_cost") + }) + t.Run("no entry cost", func(t *testing.T) { + rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, slug) VALUES ('x', 'MINIGAME', ?, 'none')`, f.org).Error, "entry_cost") + }) + t.Run("same slug twice in an organization", func(t *testing.T) { + rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, slug, entry_cost) VALUES ('x', 'MINIGAME', ?, 'tap', 1)`, f.org).Error, "uq_games_org_slug") + f.game(t, f.other, "tap") // another organization may use it + }) + + t.Run("two ACTIVE configs for a game", func(t *testing.T) { + second := f.config(t, game) + ok, err := f.games.SetRewardConfigStatus(ctx, f.org, config.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive) + require.NoError(t, err) + require.True(t, ok) + _, err = f.games.SetRewardConfigStatus(ctx, f.org, second.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive) + rejected(t, err, "uq_game_reward_configs_active") + }) + + t.Run("REFUNDED session without refund transaction", func(t *testing.T) { + session := f.session(t, game, config, time.Now().Add(time.Minute)) + rejected(t, f.db.Exec(`UPDATE game_sessions SET status = 'REFUNDED', refund_reason = 'SYSTEM_ERROR' WHERE id = ?`, session.ID).Error, "chk_game_sessions_refund") + rejected(t, f.db.Exec(`UPDATE game_sessions SET refund_transaction_id = spend_transaction_id, refund_reason = 'SYSTEM_ERROR' WHERE id = ?`, session.ID).Error, "chk_game_sessions_refund") + }) + + t.Run("two GLOBAL budgets with the same start", func(t *testing.T) { + f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31)) + err := f.budgets.CreateBudget(ctx, &entities.GameBudget{ + OrganizationID: f.org, Scope: constants.GameBudgetScopeGlobal, Name: "x", + PeriodStart: date(2026, 10, 1), PeriodEnd: date(2026, 10, 15), Amount: 1, CreatedBy: f.admin, + }) + assert.ErrorIs(t, err, ErrGameBudgetPeriodTaken, "uq_game_budgets_global_period") + f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 10, 1), date(2026, 10, 7)) + f.budget(t, f.other, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31)) + }) + + t.Run("same reward ledger row twice", func(t *testing.T) { + session := f.session(t, game, config, time.Now().Add(time.Minute)) + global := f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 11, 1), date(2026, 11, 30)) + event := f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 11, 1), date(2026, 11, 7)) + reward := f.walletTx(t, constants.WalletTxTypeGameReward, 10, nil) + err := f.sessions.CreateSessionRewards(ctx, []entities.GameSessionReward{ + {SessionID: session.ID, BudgetID: global.ID, Amount: 10, WalletTransactionID: reward}, + {SessionID: session.ID, BudgetID: event.ID, Amount: 10, WalletTransactionID: reward}, + }) + rejected(t, err, "uq_game_session_rewards_wallet_transaction") + }) +} + +// D4: of two moves out of STARTED at once, exactly one gets the row. +func TestGameSessionRepository_ConcurrentTransitionsOnlyOneWins(t *testing.T) { + f := newEnakGameFixture(t) + game := f.game(t, f.org, "race") + config := f.config(t, game) + txm := NewTxManager(f.db) + + for round := 0; round < 5; round++ { + session := f.session(t, game, config, time.Now().Add(time.Minute)) + refund := f.walletTx(t, constants.WalletTxTypeGameSpendRefund, game.EntryCost, &session.SpendTransactionID) + moves := []func(ctx context.Context) (bool, error){ + func(ctx context.Context) (bool, error) { + return f.sessions.CompleteSession(ctx, session.ID, GameSessionCompletion{ + Result: entities.JSONDocument(`{"score": 10}`), RewardTotal: 10, EndedAt: time.Now(), + }) + }, + func(ctx context.Context) (bool, error) { + return f.sessions.RefundSession(ctx, session.ID, refund, constants.GameSessionRefundSystemError, time.Now()) + }, + func(ctx context.Context) (bool, error) { return f.sessions.ExpireSession(ctx, session.ID, time.Now()) }, + } + + var wg sync.WaitGroup + var mu sync.Mutex + won := 0 + start := make(chan struct{}) + for _, move := range moves { + move := move + wg.Add(1) + go func() { + defer wg.Done() + <-start + err := txm.WithTransaction(context.Background(), func(ctx context.Context) error { + ok, err := move(ctx) + if ok { + mu.Lock() + won++ + mu.Unlock() + } + return err + }) + assert.NoError(t, err) + }() + } + close(start) + wg.Wait() + assert.Equal(t, 1, won, "round %d", round) + + got, err := f.sessions.GetSession(context.Background(), f.org, session.ID) + require.NoError(t, err) + assert.NotEqual(t, constants.GameSessionStatusStarted, got.Status) + assert.NotNil(t, got.EndedAt) + ok, err := f.sessions.MarkCompletionFailed(context.Background(), session.ID, time.Now()) + require.NoError(t, err) + assert.False(t, ok, "a session that left STARTED is not marked") + } +} + +func TestGameSessionRepository_ReadsAndMoves(t *testing.T) { + f := newEnakGameFixture(t) + game := f.game(t, f.org, "tap") + config := f.config(t, game) + ctx := context.Background() + now := time.Now() + + expired := f.session(t, game, config, now.Add(-time.Minute)) + open := f.session(t, game, config, now.Add(time.Hour)) + + got, err := f.sessions.GetCustomerSession(ctx, f.customer, open.ID) + require.NoError(t, err) + assert.Equal(t, constants.GameSessionStatusStarted, got.Status) + assert.Equal(t, game.EntryCost, got.EntryCost) + assert.Equal(t, config.ID, got.RewardConfigID) + _, err = f.sessions.GetCustomerSession(ctx, uuid.New(), open.ID) + assert.ErrorIs(t, err, ErrGameSessionNotFound, "another customer's session") + _, err = f.sessions.GetSession(ctx, f.other, open.ID) + assert.ErrorIs(t, err, ErrGameSessionNotFound, "another organization's session") + bySpend, err := f.sessions.GetSessionBySpendTransaction(ctx, open.SpendTransactionID) + require.NoError(t, err) + assert.Equal(t, open.ID, bySpend.ID) + + due, err := f.sessions.ListExpiredSessions(ctx, now, 10) + require.NoError(t, err) + require.Len(t, due, 1) + assert.Equal(t, expired.ID, due[0].ID) + byGame, err := f.sessions.ListOpenSessionsByGame(ctx, f.org, game.ID, 10) + require.NoError(t, err) + assert.Len(t, byGame, 2) + + marked, err := f.sessions.MarkCompletionFailed(ctx, open.ID, now) + require.NoError(t, err) + assert.True(t, marked) + ok, err := f.sessions.CompleteSession(ctx, open.ID, GameSessionCompletion{ + Result: entities.JSONDocument(`{"score": 120}`), RewardBreakdown: entities.JSONDocument(`{"base": 10}`), + RewardTotal: 10, Flagged: true, EndedAt: now, + }) + require.NoError(t, err) + require.True(t, ok) + got, err = f.sessions.GetSession(ctx, f.org, open.ID) + require.NoError(t, err) + assert.Equal(t, constants.GameSessionStatusCompleted, got.Status) + assert.JSONEq(t, `{"score": 120}`, string(got.Result)) + assert.JSONEq(t, `{"base": 10}`, string(got.RewardBreakdown)) + assert.EqualValues(t, 10, got.RewardTotal) + assert.True(t, got.Flagged) + assert.NotNil(t, got.CompletionFailedAt) + + sessions, total, err := f.sessions.ListCustomerSessions(ctx, f.customer, 0, 1) + require.NoError(t, err) + assert.EqualValues(t, 2, total) + assert.Len(t, sessions, 1) + + budget := f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31)) + reward := f.walletTx(t, constants.WalletTxTypeGameReward, 10, nil) + require.NoError(t, f.sessions.CreateSessionRewards(ctx, []entities.GameSessionReward{ + {SessionID: open.ID, BudgetID: budget.ID, Amount: 10, WalletTransactionID: reward}, + })) + rewards, err := f.sessions.ListSessionRewards(ctx, open.ID) + require.NoError(t, err) + assert.Equal(t, []entities.GameSessionReward{{SessionID: open.ID, BudgetID: budget.ID, Amount: 10, WalletTransactionID: reward}}, rewards) +} + +func TestEnakGameRepository_GamesAndRewardConfigs(t *testing.T) { + f := newEnakGameFixture(t) + ctx := context.Background() + game := f.game(t, f.org, "tap") + theirs := f.game(t, f.other, "theirs") + max := int64(1000) + game.ResultRules = entities.GameResultRules{MaxScore: &max, Outcomes: []string{"WIN", "LOSE"}} + game.EntryCost = 7 + require.NoError(t, f.games.UpdateGame(ctx, game)) + + got, err := f.games.GetGame(ctx, f.org, game.ID) + require.NoError(t, err) + assert.EqualValues(t, 7, got.EntryCost) + assert.Equal(t, game.ResultRules, got.ResultRules) + _, err = f.games.GetGame(ctx, f.org, theirs.ID) + assert.ErrorIs(t, err, ErrEnakGameNotFound, "another organization's game") + theirs.OrganizationID = &f.org + assert.ErrorIs(t, f.games.UpdateGame(ctx, theirs), ErrEnakGameNotFound) + assert.ErrorIs(t, f.games.SetGameStatus(ctx, f.org, theirs.ID, constants.GameStatusInactive), ErrEnakGameNotFound) + + games, total, err := f.games.ListGames(ctx, EnakGameFilter{OrganizationID: f.org, Limit: 10}) + require.NoError(t, err) + assert.EqualValues(t, 1, total, "only the organization's games; the archived old ones have none") + assert.Equal(t, game.ID, games[0].ID) + + require.NoError(t, f.games.SetGameStatus(ctx, f.org, game.ID, constants.GameStatusArchived)) + assert.ErrorIs(t, f.games.SetGameStatus(ctx, f.org, game.ID, constants.GameStatusActive), ErrEnakGameNotFound, "an archived game never changes") + assert.ErrorIs(t, f.games.UpdateGame(ctx, game), ErrEnakGameNotFound) + games, _, err = f.games.ListGames(ctx, EnakGameFilter{OrganizationID: f.org, Statuses: []string{constants.GameStatusActive}, Limit: 10}) + require.NoError(t, err) + assert.Empty(t, games) + + // Reward configs: versions count up per game, only status ever changes. + other := f.game(t, f.org, "other") + v1, v2 := f.config(t, other), f.config(t, other) + assert.Equal(t, 1, v1.Version) + assert.Equal(t, 2, v2.Version) + assert.Equal(t, 1, f.config(t, game).Version, "per game") + err = f.games.CreateRewardConfig(ctx, &entities.GameRewardConfig{ + OrganizationID: f.other, GameID: other.ID, RewardType: constants.GameRewardTypeFixed, + Rules: entities.JSONDocument(`{}`), CreatedBy: f.admin, + }) + assert.ErrorIs(t, err, ErrEnakGameNotFound, "a config for another organization's game") + + _, err = f.games.GetActiveRewardConfig(ctx, f.org, other.ID) + assert.ErrorIs(t, err, ErrGameRewardConfigNotFound) + ok, err := f.games.SetRewardConfigStatus(ctx, f.org, v2.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive) + require.NoError(t, err) + require.True(t, ok) + ok, err = f.games.SetRewardConfigStatus(ctx, f.org, v2.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive) + require.NoError(t, err) + assert.False(t, ok, "no longer DRAFT") + ok, err = f.games.SetRewardConfigStatus(ctx, f.other, v1.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusRetired) + require.NoError(t, err) + assert.False(t, ok, "another organization") + + active, err := f.games.GetActiveRewardConfig(ctx, f.org, other.ID) + require.NoError(t, err) + assert.Equal(t, v2.ID, active.ID) + assert.JSONEq(t, `{"amount": 10}`, string(active.Rules)) + configs, err := f.games.ListRewardConfigs(ctx, f.org, other.ID) + require.NoError(t, err) + require.Len(t, configs, 2) + assert.Equal(t, []int{2, 1}, []int{configs[0].Version, configs[1].Version}) + _, err = f.games.GetRewardConfig(ctx, f.other, v1.ID) + assert.ErrorIs(t, err, ErrGameRewardConfigNotFound) + + // LockGame takes the row lock: a second locker waits for the first transaction. + txm := NewTxManager(f.db) + locked := make(chan struct{}) + release := make(chan struct{}) + go func() { + _ = txm.WithTransaction(ctx, func(ctx context.Context) error { + _, err := f.games.LockGame(ctx, f.org, other.ID) + assert.NoError(t, err) + close(locked) + <-release + return nil + }) + }() + <-locked + acquired := make(chan struct{}) + go func() { + _ = txm.WithTransaction(ctx, func(ctx context.Context) error { + _, err := f.games.LockGame(ctx, f.org, other.ID) + assert.NoError(t, err) + return nil + }) + close(acquired) + }() + select { + case <-acquired: + t.Fatal("the second lock did not wait") + case <-time.After(200 * time.Millisecond): + } + close(release) + <-acquired +} + +func TestGameBudgetRepository(t *testing.T) { + f := newEnakGameFixture(t) + ctx := context.Background() + // A late evening in Jakarta is still the previous day in UTC: the date is taken as + // given, not converted. + jakarta := time.FixedZone("WIB", 7*3600) + october := f.budget(t, f.org, constants.GameBudgetScopeGlobal, + time.Date(2026, 10, 1, 0, 30, 0, 0, jakarta), time.Date(2026, 10, 31, 23, 30, 0, 0, jakarta)) + f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 11, 1), date(2026, 11, 30)) + event := f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 10, 10), date(2026, 10, 20)) + f.budget(t, f.other, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31)) + + got, err := f.budgets.GetBudget(ctx, f.org, october.ID) + require.NoError(t, err) + assert.Equal(t, "2026-10-01", got.PeriodStart.Format("2006-01-02")) + assert.Equal(t, "2026-10-31", got.PeriodEnd.Format("2006-01-02")) + assert.JSONEq(t, `{}`, string(got.Thresholds)) + _, err = f.budgets.GetBudget(ctx, f.other, october.ID) + assert.ErrorIs(t, err, ErrGameBudgetNotFound) + + for day, want := range map[time.Time]uuid.UUID{ + date(2026, 10, 1): october.ID, + date(2026, 10, 15): october.ID, + date(2026, 10, 31): october.ID, + } { + got, err := f.budgets.GetGlobalBudgetOn(ctx, f.org, day) + require.NoError(t, err) + assert.Equal(t, want, got.ID, day) + } + _, err = f.budgets.GetGlobalBudgetOn(ctx, f.org, date(2026, 9, 30)) + assert.ErrorIs(t, err, ErrGameBudgetNotFound) + + event.Amount = 5_000_000 + event.Thresholds = entities.JSONDocument(`{"warning": 70, "critical": 90}`) + require.NoError(t, f.budgets.UpdateBudget(ctx, event)) + got, err = f.budgets.GetBudget(ctx, f.org, event.ID) + require.NoError(t, err) + assert.EqualValues(t, 5_000_000, got.Amount) + assert.JSONEq(t, `{"warning": 70, "critical": 90}`, string(got.Thresholds)) + event.OrganizationID = f.other + assert.ErrorIs(t, f.budgets.UpdateBudget(ctx, event), ErrGameBudgetNotFound) + + budgets, total, err := f.budgets.ListBudgets(ctx, GameBudgetFilter{OrganizationID: f.org, Scope: constants.GameBudgetScopeGlobal, Limit: 10}) + require.NoError(t, err) + assert.EqualValues(t, 2, total) + assert.Equal(t, "2026-11-01", budgets[0].PeriodStart.Format("2006-01-02"), "latest period first") +} + +// EG-701: what game_events refuses. +func TestGameEventSchema_RejectsWhatTheRFCForbids(t *testing.T) { + f := newEnakGameFixture(t) + event := f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 10, 1), date(2026, 10, 7)) + t.Cleanup(func() { f.db.Exec(`DELETE FROM game_events WHERE organization_id = ?`, f.org) }) + insert := func(budget any, start, end string) error { + return f.db.Exec(`INSERT INTO game_events (organization_id, name, slug, start_at, end_at, budget_id) + VALUES (?, 'e', ?, ?, ?, ?)`, f.org, uuid.NewString(), start, end, budget).Error + } + err := insert(nil, "2026-10-01T00:00:00Z", "2026-10-02T00:00:00Z") + require.Error(t, err) + assert.Contains(t, err.Error(), "budget_id") + err = insert(event.ID, "2026-10-02T00:00:00Z", "2026-10-02T00:00:00Z") + require.Error(t, err) + assert.Contains(t, err.Error(), "chk_game_events_period") + err = insert(event.ID, "2026-10-02T00:00:00Z", "2026-10-01T00:00:00Z") + require.Error(t, err) + assert.Contains(t, err.Error(), "chk_game_events_period") + require.NoError(t, insert(event.ID, "2026-10-01T00:00:00Z", "2026-10-02T00:00:00Z")) +} diff --git a/internal/repository/game_budget_metrics_repository.go b/internal/repository/game_budget_metrics_repository.go new file mode 100644 index 0000000..1e2fd62 --- /dev/null +++ b/internal/repository/game_budget_metrics_repository.go @@ -0,0 +1,95 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + "apskel-pos-be/internal/constants" +) + +// GameBudgetMetricsRepository reads what a budget's metrics are computed from +// (docs/rfc-enakgame.md §10), on read, from the tables that hold it. +type GameBudgetMetricsRepository interface { + // RealizedCost sums the voucher costs recognized against a budget at or after from + // and before to; a nil bound is open. EnakPoint not from EnakGame names no budget, + // so it never counts (D5). + RealizedCost(ctx context.Context, budgetID uuid.UUID, from, to *time.Time) (int64, error) + // CoinIssued sums the EnakCoin rewards the budget paid for. + CoinIssued(ctx context.Context, budgetID uuid.UUID) (int64, error) + // Exposure sums, per currency, what is still spendable at asOf of the balances the + // budget's rewards created, following them through transfers and exchanges: the + // most that can still turn into cost. + Exposure(ctx context.Context, budgetID uuid.UUID, asOf time.Time) (coins, points int64, err error) +} + +type gameBudgetMetricsRepository struct { + db *gorm.DB +} + +func NewGameBudgetMetricsRepository(db *gorm.DB) GameBudgetMetricsRepository { + return &gameBudgetMetricsRepository{db: db} +} + +func (r *gameBudgetMetricsRepository) RealizedCost(ctx context.Context, budgetID uuid.UUID, from, to *time.Time) (int64, error) { + q := DBFromContext(ctx, r.db).WithContext(ctx).Table("voucher_redemption_costs").Where("budget_id = ?", budgetID) + if from != nil { + q = q.Where("recognized_at >= ?", *from) + } + if to != nil { + q = q.Where("recognized_at < ?", *to) + } + var total int64 + if err := q.Select("COALESCE(SUM(cost), 0)").Scan(&total).Error; err != nil { + return 0, fmt.Errorf("failed to sum realized cost: %w", err) + } + return total, nil +} + +func (r *gameBudgetMetricsRepository) CoinIssued(ctx context.Context, budgetID uuid.UUID) (int64, error) { + var total int64 + err := DBFromContext(ctx, r.db).WithContext(ctx).Table("game_session_rewards").Where("budget_id = ?", budgetID). + Select("COALESCE(SUM(amount), 0)").Scan(&total).Error + if err != nil { + return 0, fmt.Errorf("failed to sum issued EnakCoin: %w", err) + } + return total, nil +} + +func (r *gameBudgetMetricsRepository) Exposure(ctx context.Context, budgetID uuid.UUID, asOf time.Time) (int64, int64, error) { + var rows []struct { + Currency string + Total int64 + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + WITH RECURSIVE tree AS ( + SELECT l.id, l.currency, l.remaining_amount, l.expires_at + FROM game_session_rewards r + JOIN wallet_lots l ON l.source_transaction_id = r.wallet_transaction_id + WHERE r.budget_id = ? + UNION ALL + SELECT c.id, c.currency, c.remaining_amount, c.expires_at + FROM tree t + JOIN wallet_lots c ON c.origin_lot_id = t.id + ) + SELECT currency, COALESCE(SUM(remaining_amount), 0) AS total + FROM tree + WHERE remaining_amount > 0 AND (expires_at IS NULL OR expires_at > ?) + GROUP BY currency`, budgetID, asOf).Scan(&rows).Error + if err != nil { + return 0, 0, fmt.Errorf("failed to sum budget exposure: %w", err) + } + var coins, points int64 + for _, row := range rows { + switch row.Currency { + case constants.WalletCurrencyCoin: + coins = row.Total + case constants.WalletCurrencyPoint: + points = row.Total + } + } + return coins, points, nil +} diff --git a/internal/repository/game_budget_repository.go b/internal/repository/game_budget_repository.go new file mode 100644 index 0000000..cb99fe6 --- /dev/null +++ b/internal/repository/game_budget_repository.go @@ -0,0 +1,233 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" +) + +var ( + // ErrGameBudgetNotFound means no budget with that id in the organization, or no + // global budget covering the day asked for. + ErrGameBudgetNotFound = errors.New("enakgame: budget not found") + // ErrGameBudgetPeriodTaken means a global budget of the organization already + // starts on that day. + ErrGameBudgetPeriodTaken = errors.New("enakgame: a global budget already starts on that day") + // ErrGameBudgetInUse means rewards or events point at the budget, so it stays. + ErrGameBudgetInUse = errors.New("enakgame: budget is in use") + // ErrGameBudgetTxRequired is returned by LockGlobalBudgets outside a transaction, + // where the lock would be released at once. + ErrGameBudgetTxRequired = errors.New("enakgame: budget lock must run inside a transaction") +) + +// GameBudgetFilter selects an organization's budgets. +type GameBudgetFilter struct { + OrganizationID uuid.UUID + // Empty for both scopes. + Scope string + Offset int + Limit int +} + +// GameBudgetRepository stores EnakGame budgets (docs/rfc-enakgame.md §5.6), always +// scoped to an organization. +// +// Period dates are written and compared as dates (YYYY-MM-DD of the time given), so +// the database session's time zone cannot move them to another day. +type GameBudgetRepository interface { + CreateBudget(ctx context.Context, budget *entities.GameBudget) error + GetBudget(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameBudget, error) + // UpdateBudget stores the name, period, amount, thresholds and exhaustion policy. + UpdateBudget(ctx context.Context, budget *entities.GameBudget) error + // ListBudgets returns a page of budgets, latest period first, and the total. + ListBudgets(ctx context.Context, filter GameBudgetFilter) ([]entities.GameBudget, int64, error) + // GetGlobalBudgetOn returns the organization's global budget whose period holds + // day, a date in Asia/Jakarta. + GetGlobalBudgetOn(ctx context.Context, organizationID uuid.UUID, day time.Time) (*entities.GameBudget, error) + // DeleteBudget removes a budget nothing points at; otherwise ErrGameBudgetInUse. + DeleteBudget(ctx context.Context, organizationID, id uuid.UUID) error + + // LockGlobalBudgets serializes the writers of an organization's global budgets + // until the transaction ends, so two of them cannot both find a period free. + LockGlobalBudgets(ctx context.Context, organizationID uuid.UUID) error + // GlobalBudgetOverlaps reports whether another global budget of the organization + // shares a day with start..end. except is the budget being changed, if any. + GlobalBudgetOverlaps(ctx context.Context, organizationID uuid.UUID, start, end time.Time, except *uuid.UUID) (bool, error) + // CreateNextGlobalBudgets gives every global budget holding today, whose next day + // no global budget holds yet, a successor: from that next day to the end of its + // month, with the same amount, thresholds and policy (§12). It returns what it + // made. + CreateNextGlobalBudgets(ctx context.Context, today time.Time) ([]entities.GameBudget, error) +} + +type gameBudgetRepository struct { + db *gorm.DB +} + +func NewGameBudgetRepository(db *gorm.DB) GameBudgetRepository { + return &gameBudgetRepository{db: db} +} + +func budgetDate(t time.Time) string { return t.Format("2006-01-02") } + +func (r *gameBudgetRepository) CreateBudget(ctx context.Context, budget *entities.GameBudget) error { + if budget.ID == uuid.Nil { + budget.ID = uuid.New() + } + thresholds := budget.Thresholds + if len(thresholds) == 0 { + thresholds = entities.JSONDocument(`{}`) + } + var rows []struct{ CreatedAt, UpdatedAt time.Time } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + INSERT INTO game_budgets (id, organization_id, scope, name, period_start, period_end, amount, + thresholds, exhaustion_policy, created_by) + VALUES (?, ?, ?, ?, ?::date, ?::date, ?, ?::jsonb, ?, ?) + RETURNING created_at, updated_at`, + budget.ID, budget.OrganizationID, budget.Scope, budget.Name, budgetDate(budget.PeriodStart), budgetDate(budget.PeriodEnd), + budget.Amount, thresholds, budget.ExhaustionPolicy, budget.CreatedBy).Scan(&rows).Error + if err != nil { + if isUniqueViolation(err, "uq_game_budgets_global_period") { + return ErrGameBudgetPeriodTaken + } + return fmt.Errorf("failed to create game budget: %w", err) + } + budget.Thresholds = thresholds + budget.CreatedAt, budget.UpdatedAt = rows[0].CreatedAt, rows[0].UpdatedAt + return nil +} + +func (r *gameBudgetRepository) first(q *gorm.DB) (*entities.GameBudget, error) { + var budget entities.GameBudget + if err := q.First(&budget).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGameBudgetNotFound + } + return nil, fmt.Errorf("failed to read game budget: %w", err) + } + return &budget, nil +} + +func (r *gameBudgetRepository) GetBudget(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameBudget, error) { + return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("organization_id = ? AND id = ?", organizationID, id)) +} + +func (r *gameBudgetRepository) UpdateBudget(ctx context.Context, budget *entities.GameBudget) error { + thresholds := budget.Thresholds + if len(thresholds) == 0 { + thresholds = entities.JSONDocument(`{}`) + } + result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` + UPDATE game_budgets SET name = ?, period_start = ?::date, period_end = ?::date, amount = ?, + thresholds = ?::jsonb, exhaustion_policy = ?, updated_at = NOW() + WHERE organization_id = ? AND id = ?`, + budget.Name, budgetDate(budget.PeriodStart), budgetDate(budget.PeriodEnd), budget.Amount, + thresholds, budget.ExhaustionPolicy, budget.OrganizationID, budget.ID) + if result.Error != nil { + if isUniqueViolation(result.Error, "uq_game_budgets_global_period") { + return ErrGameBudgetPeriodTaken + } + return fmt.Errorf("failed to update game budget: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrGameBudgetNotFound + } + budget.Thresholds = thresholds + return nil +} + +func (r *gameBudgetRepository) ListBudgets(ctx context.Context, filter GameBudgetFilter) ([]entities.GameBudget, int64, error) { + q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameBudget{}).Where("organization_id = ?", filter.OrganizationID) + if filter.Scope != "" { + q = q.Where("scope = ?", filter.Scope) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count game budgets: %w", err) + } + var budgets []entities.GameBudget + if err := q.Order("period_start DESC, created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&budgets).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list game budgets: %w", err) + } + return budgets, total, nil +} + +func (r *gameBudgetRepository) GetGlobalBudgetOn(ctx context.Context, organizationID uuid.UUID, day time.Time) (*entities.GameBudget, error) { + d := budgetDate(day) + return r.first(DBFromContext(ctx, r.db).WithContext(ctx). + Where("organization_id = ? AND scope = ? AND period_start <= ?::date AND period_end >= ?::date", + organizationID, constants.GameBudgetScopeGlobal, d, d). + Order("period_start DESC")) +} + +func (r *gameBudgetRepository) DeleteBudget(ctx context.Context, organizationID, id uuid.UUID) error { + result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`DELETE FROM game_budgets WHERE organization_id = ? AND id = ?`, organizationID, id) + if result.Error != nil { + if isForeignKeyViolation(result.Error) { + return ErrGameBudgetInUse + } + return fmt.Errorf("failed to delete game budget: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrGameBudgetNotFound + } + return nil +} + +func (r *gameBudgetRepository) LockGlobalBudgets(ctx context.Context, organizationID uuid.UUID) error { + tx, ok := ctx.Value(txKey).(*gorm.DB) + if !ok || tx == nil { + return ErrGameBudgetTxRequired + } + err := tx.WithContext(ctx).Exec(`SELECT pg_advisory_xact_lock(hashtextextended(?, 0))`, "game_budgets:global:"+organizationID.String()).Error + if err != nil { + return fmt.Errorf("failed to lock game budgets: %w", err) + } + return nil +} + +func (r *gameBudgetRepository) GlobalBudgetOverlaps(ctx context.Context, organizationID uuid.UUID, start, end time.Time, except *uuid.UUID) (bool, error) { + q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameBudget{}). + Where("organization_id = ? AND scope = ? AND period_start <= ?::date AND period_end >= ?::date", + organizationID, constants.GameBudgetScopeGlobal, budgetDate(end), budgetDate(start)) + if except != nil { + q = q.Where("id <> ?", *except) + } + var count int64 + if err := q.Count(&count).Error; err != nil { + return false, fmt.Errorf("failed to check game budget periods: %w", err) + } + return count > 0, nil +} + +func (r *gameBudgetRepository) CreateNextGlobalBudgets(ctx context.Context, today time.Time) ([]entities.GameBudget, error) { + d := budgetDate(today) + var created []entities.GameBudget + // ON CONFLICT covers another instance creating the same successor at once. + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + INSERT INTO game_budgets (organization_id, scope, name, period_start, period_end, amount, + thresholds, exhaustion_policy, created_by) + SELECT b.organization_id, b.scope, 'Budget global ' || to_char(b.period_end + 1, 'YYYY-MM'), + b.period_end + 1, + (date_trunc('month', b.period_end + 1) + INTERVAL '1 month - 1 day')::date, + b.amount, b.thresholds, b.exhaustion_policy, b.created_by + FROM game_budgets b + WHERE b.scope = ? AND b.period_start <= ?::date AND b.period_end >= ?::date + AND NOT EXISTS ( + SELECT 1 FROM game_budgets n + WHERE n.organization_id = b.organization_id AND n.scope = b.scope + AND n.period_start <= b.period_end + 1 AND n.period_end >= b.period_end + 1) + ON CONFLICT DO NOTHING + RETURNING *`, constants.GameBudgetScopeGlobal, d, d).Scan(&created).Error + if err != nil { + return nil, fmt.Errorf("failed to create next game budgets: %w", err) + } + return created, nil +} diff --git a/internal/repository/game_event_repository.go b/internal/repository/game_event_repository.go new file mode 100644 index 0000000..1ab7d71 --- /dev/null +++ b/internal/repository/game_event_repository.go @@ -0,0 +1,217 @@ +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" +) + +var ( + // ErrGameEventNotFound means no event with that id in the organization. + ErrGameEventNotFound = errors.New("enakgame: event not found") + // ErrGameEventSlugTaken means another event of the organization has the slug. + ErrGameEventSlugTaken = errors.New("enakgame: event slug already used") +) + +// GameEventFilter selects an organization's events. +type GameEventFilter struct { + OrganizationID uuid.UUID + // Empty for every status. + Statuses []string + Offset int + Limit int +} + +// GameEventRepository stores EnakGame events and the games they cover +// (docs/rfc-enakgame.md §5.5), always scoped to an organization. +type GameEventRepository interface { + // CreateEvent stores an event and the games it covers. + CreateEvent(ctx context.Context, event *entities.GameEvent, gameIDs []uuid.UUID) error + GetEvent(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameEvent, error) + // LockEvent is GetEvent with the row locked until the transaction ends. + LockEvent(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameEvent, error) + // EventGames returns the games each event covers. + EventGames(ctx context.Context, eventIDs []uuid.UUID) (map[uuid.UUID][]uuid.UUID, error) + // ListEvents returns a page of events, latest start first, and the total. + ListEvents(ctx context.Context, filter GameEventFilter) ([]entities.GameEvent, int64, error) + // UpdateEvent stores everything but the organization and the status, and replaces + // the games it covers. + UpdateEvent(ctx context.Context, event *entities.GameEvent, gameIDs []uuid.UUID) error + SetEventStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error + + // ActiveEventsForGame returns the events changing a game's rewards at now: ACTIVE, + // started, not ended. Highest priority first, then oldest. + ActiveEventsForGame(ctx context.Context, organizationID, gameID uuid.UUID, now time.Time) ([]entities.GameEvent, error) + // ActiveEventsByGame is ActiveEventsForGame for every game of the organization. + ActiveEventsByGame(ctx context.Context, organizationID uuid.UUID, now time.Time) (map[uuid.UUID][]entities.GameEvent, error) +} + +type gameEventRepository struct { + db *gorm.DB +} + +func NewGameEventRepository(db *gorm.DB) GameEventRepository { + return &gameEventRepository{db: db} +} + +func (r *gameEventRepository) CreateEvent(ctx context.Context, event *entities.GameEvent, gameIDs []uuid.UUID) error { + db := DBFromContext(ctx, r.db).WithContext(ctx) + if err := db.Create(event).Error; err != nil { + if isUniqueViolation(err, "uq_game_events_slug") { + return ErrGameEventSlugTaken + } + return fmt.Errorf("failed to create game event: %w", err) + } + return r.linkGames(db, event.ID, gameIDs) +} + +func (r *gameEventRepository) linkGames(db *gorm.DB, eventID uuid.UUID, gameIDs []uuid.UUID) error { + for _, gameID := range gameIDs { + if err := db.Exec(`INSERT INTO game_event_games (event_id, game_id) VALUES (?, ?) ON CONFLICT DO NOTHING`, eventID, gameID).Error; err != nil { + return fmt.Errorf("failed to link game to event: %w", err) + } + } + return nil +} + +func (r *gameEventRepository) getEvent(ctx context.Context, organizationID, id uuid.UUID, lock bool) (*entities.GameEvent, 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 event entities.GameEvent + if err := q.First(&event).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGameEventNotFound + } + return nil, fmt.Errorf("failed to read game event: %w", err) + } + return &event, nil +} + +func (r *gameEventRepository) GetEvent(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameEvent, error) { + return r.getEvent(ctx, organizationID, id, false) +} + +func (r *gameEventRepository) LockEvent(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameEvent, error) { + return r.getEvent(ctx, organizationID, id, true) +} + +func (r *gameEventRepository) EventGames(ctx context.Context, eventIDs []uuid.UUID) (map[uuid.UUID][]uuid.UUID, error) { + out := map[uuid.UUID][]uuid.UUID{} + if len(eventIDs) == 0 { + return out, nil + } + var rows []struct{ EventID, GameID string } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT event_id::text AS event_id, game_id::text AS game_id FROM game_event_games + WHERE event_id IN ? ORDER BY game_id`, eventIDs).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to read event games: %w", err) + } + for _, row := range rows { + eventID, _ := uuid.Parse(row.EventID) + gameID, _ := uuid.Parse(row.GameID) + out[eventID] = append(out[eventID], gameID) + } + return out, nil +} + +func (r *gameEventRepository) ListEvents(ctx context.Context, filter GameEventFilter) ([]entities.GameEvent, int64, error) { + q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameEvent{}).Where("organization_id = ?", filter.OrganizationID) + if len(filter.Statuses) > 0 { + q = q.Where("status IN ?", filter.Statuses) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count game events: %w", err) + } + var events []entities.GameEvent + if err := q.Order("start_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&events).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list game events: %w", err) + } + return events, total, nil +} + +func (r *gameEventRepository) UpdateEvent(ctx context.Context, e *entities.GameEvent, gameIDs []uuid.UUID) error { + db := DBFromContext(ctx, r.db).WithContext(ctx) + result := db.Exec(` + UPDATE game_events SET name = ?, slug = ?, description = ?, banner_url = ?, start_at = ?, end_at = ?, + timezone = ?, priority = ?, multiplier = ?, bonus = ?, budget_id = ?, reward_limit = ?, + user_daily_limit = ?, updated_at = NOW() + WHERE organization_id = ? AND id = ?`, + e.Name, e.Slug, e.Description, e.BannerURL, e.StartAt, e.EndAt, e.Timezone, e.Priority, e.Multiplier, + e.Bonus, e.BudgetID, e.RewardLimit, e.UserDailyLimit, e.OrganizationID, e.ID) + if result.Error != nil { + if isUniqueViolation(result.Error, "uq_game_events_slug") { + return ErrGameEventSlugTaken + } + return fmt.Errorf("failed to update game event: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrGameEventNotFound + } + if err := db.Exec(`DELETE FROM game_event_games WHERE event_id = ?`, e.ID).Error; err != nil { + return fmt.Errorf("failed to unlink event games: %w", err) + } + return r.linkGames(db, e.ID, gameIDs) +} + +func (r *gameEventRepository) SetEventStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error { + result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` + UPDATE game_events SET status = ?, updated_at = NOW() WHERE organization_id = ? AND id = ?`, status, organizationID, id) + if result.Error != nil { + return fmt.Errorf("failed to change game event status: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrGameEventNotFound + } + return nil +} + +func (r *gameEventRepository) ActiveEventsForGame(ctx context.Context, organizationID, gameID uuid.UUID, now time.Time) ([]entities.GameEvent, error) { + var events []entities.GameEvent + err := DBFromContext(ctx, r.db).WithContext(ctx). + Joins("JOIN game_event_games eg ON eg.event_id = game_events.id"). + Where("game_events.organization_id = ? AND eg.game_id = ? AND game_events.status = ? AND game_events.start_at <= ? AND game_events.end_at > ?", + organizationID, gameID, constants.GameEventStatusActive, now, now). + Order("game_events.priority DESC, game_events.created_at, game_events.id"). + Find(&events).Error + if err != nil { + return nil, fmt.Errorf("failed to read active game events: %w", err) + } + return events, nil +} + +func (r *gameEventRepository) ActiveEventsByGame(ctx context.Context, organizationID uuid.UUID, now time.Time) (map[uuid.UUID][]entities.GameEvent, error) { + db := DBFromContext(ctx, r.db).WithContext(ctx) + var events []entities.GameEvent + err := db.Where("organization_id = ? AND status = ? AND start_at <= ? AND end_at > ?", organizationID, constants.GameEventStatusActive, now, now). + Order("priority DESC, created_at, id").Find(&events).Error + if err != nil { + return nil, fmt.Errorf("failed to read active game events: %w", err) + } + ids := make([]uuid.UUID, 0, len(events)) + for _, e := range events { + ids = append(ids, e.ID) + } + games, err := r.EventGames(ctx, ids) + if err != nil { + return nil, err + } + out := map[uuid.UUID][]entities.GameEvent{} + for _, e := range events { + for _, gameID := range games[e.ID] { + out[gameID] = append(out[gameID], e) + } + } + return out, nil +} diff --git a/internal/repository/game_reward_counter_repository.go b/internal/repository/game_reward_counter_repository.go new file mode 100644 index 0000000..b11ca98 --- /dev/null +++ b/internal/repository/game_reward_counter_repository.go @@ -0,0 +1,133 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ErrGameRewardCounterTxRequired is returned outside a transaction: the counter row +// stays locked until the reward it counts commits or rolls back. +var ErrGameRewardCounterTxRequired = errors.New("enakgame: reward counter must change inside a transaction") + +// GameRewardCounterRepository keeps the Economy Guard counters (docs/rfc-enakgame.md +// §5.8, §9). Days are dates in Asia/Jakarta, taken as given. +type GameRewardCounterRepository interface { + // Consume adds up to amount to a counter without taking it past limit, and returns + // what it added: amount, what was left under the limit, or 0. A limit of 0 or less + // is no limit, and the counter still counts. The counter row stays locked until + // the transaction ends, so rewards at the same time never pass the limit together. + Consume(ctx context.Context, organizationID uuid.UUID, scopeType string, scopeID uuid.UUID, day time.Time, amount, limit int64) (int64, error) + // Release takes back what Consume added and was not given out after all. + Release(ctx context.Context, organizationID uuid.UUID, scopeType string, scopeID uuid.UUID, day time.Time, amount int64) error + // Get returns a counter, 0 when it has never counted anything. + Get(ctx context.Context, organizationID uuid.UUID, scopeType string, scopeID uuid.UUID, day time.Time) (int64, error) +} + +type gameRewardCounterRepository struct { + db *gorm.DB +} + +func NewGameRewardCounterRepository(db *gorm.DB) GameRewardCounterRepository { + return &gameRewardCounterRepository{db: db} +} + +func counterDay(t time.Time) string { return t.Format("2006-01-02") } + +func (r *gameRewardCounterRepository) txDB(ctx context.Context) (*gorm.DB, error) { + if tx, ok := ctx.Value(txKey).(*gorm.DB); ok && tx != nil { + return tx.WithContext(ctx), nil + } + return nil, ErrGameRewardCounterTxRequired +} + +func (r *gameRewardCounterRepository) Consume(ctx context.Context, organizationID uuid.UUID, scopeType string, scopeID uuid.UUID, day time.Time, amount, limit int64) (int64, error) { + if amount <= 0 { + return 0, nil + } + db, err := r.txDB(ctx) + if err != nil { + return 0, err + } + d := counterDay(day) + // Make sure the row exists, then lock it: a waiter reads the amount the holder + // committed, so the check below always sees every reward counted before it. + err = db.Exec(` + INSERT INTO game_reward_counters (organization_id, scope_type, scope_id, day, amount) + VALUES (?, ?, ?, ?::date, 0) ON CONFLICT DO NOTHING`, organizationID, scopeType, scopeID, d).Error + if err != nil { + return 0, fmt.Errorf("failed to create reward counter: %w", err) + } + var current []int64 + err = db.Raw(` + SELECT amount FROM game_reward_counters + WHERE organization_id = ? AND scope_type = ? AND scope_id = ? AND day = ?::date + FOR UPDATE`, organizationID, scopeType, scopeID, d).Scan(¤t).Error + if err != nil { + return 0, fmt.Errorf("failed to lock reward counter: %w", err) + } + if len(current) != 1 { + return 0, fmt.Errorf("reward counter %s %s %s is missing", scopeType, scopeID, d) + } + granted := amount + if limit > 0 { + left := limit - current[0] + if left < 0 { + left = 0 + } + if granted > left { + granted = left + } + } + if granted == 0 { + return 0, nil + } + err = db.Exec(` + UPDATE game_reward_counters SET amount = amount + ? + WHERE organization_id = ? AND scope_type = ? AND scope_id = ? AND day = ?::date`, + granted, organizationID, scopeType, scopeID, d).Error + if err != nil { + return 0, fmt.Errorf("failed to count reward: %w", err) + } + return granted, nil +} + +func (r *gameRewardCounterRepository) Release(ctx context.Context, organizationID uuid.UUID, scopeType string, scopeID uuid.UUID, day time.Time, amount int64) error { + if amount <= 0 { + return nil + } + db, err := r.txDB(ctx) + if err != nil { + return err + } + result := db.Exec(` + UPDATE game_reward_counters SET amount = amount - ? + WHERE organization_id = ? AND scope_type = ? AND scope_id = ? AND day = ?::date AND amount >= ?`, + amount, organizationID, scopeType, scopeID, counterDay(day), amount) + if result.Error != nil { + return fmt.Errorf("failed to release reward counter: %w", result.Error) + } + if result.RowsAffected != 1 { + return fmt.Errorf("reward counter %s %s holds less than %d to release", scopeType, scopeID, amount) + } + return nil +} + +func (r *gameRewardCounterRepository) Get(ctx context.Context, organizationID uuid.UUID, scopeType string, scopeID uuid.UUID, day time.Time) (int64, error) { + var amounts []int64 + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT amount FROM game_reward_counters + WHERE organization_id = ? AND scope_type = ? AND scope_id = ? AND day = ?::date`, + organizationID, scopeType, scopeID, counterDay(day)).Scan(&amounts).Error + if err != nil { + return 0, fmt.Errorf("failed to read reward counter: %w", err) + } + if len(amounts) == 0 { + return 0, nil + } + return amounts[0], nil +} diff --git a/internal/repository/game_reward_counter_repository_test.go b/internal/repository/game_reward_counter_repository_test.go new file mode 100644 index 0000000..4122638 --- /dev/null +++ b/internal/repository/game_reward_counter_repository_test.go @@ -0,0 +1,110 @@ +package repository + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" +) + +// The Economy Guard counters against Postgres (docs/rfc-enakgame.md §9). Needs +// TEST_DATABASE_URL; see wallet_repository_test.go. +func TestGameRewardCounterRepository_ConcurrentConsumeNeverPassesTheLimit(t *testing.T) { + db := walletTestDB(t) + org, game := uuid.New(), uuid.New() + t.Cleanup(func() { db.Exec(`DELETE FROM game_reward_counters WHERE organization_id = ?`, org) }) + repo := NewGameRewardCounterRepository(db) + txm := NewTxManager(db) + day := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) + + for _, round := range []struct { + each int64 + wantTotal int64 + }{{10, 100}, {7, 100}} { + var granted atomic.Int64 + var wg sync.WaitGroup + begin := make(chan struct{}) + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-begin + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + got, err := repo.Consume(ctx, org, constants.GameRewardScopeGame, game, day, round.each, 100) + granted.Add(got) + return err + })) + }() + } + close(begin) + wg.Wait() + total, err := repo.Get(context.Background(), org, constants.GameRewardScopeGame, game, day) + require.NoError(t, err) + assert.EqualValues(t, round.wantTotal, total) + if round.each == 10 { + assert.EqualValues(t, 100, granted.Load(), "exactly what fits") + } else { + assert.Zero(t, granted.Load(), "nothing is left") + } + } +} + +func TestGameRewardCounterRepository(t *testing.T) { + db := walletTestDB(t) + org, customer := uuid.New(), uuid.New() + t.Cleanup(func() { db.Exec(`DELETE FROM game_reward_counters WHERE organization_id = ?`, org) }) + repo := NewGameRewardCounterRepository(db) + txm := NewTxManager(db) + ctx := context.Background() + today, tomorrow := time.Date(2026, 10, 7, 23, 0, 0, 0, time.FixedZone("WIB", 7*3600)), time.Date(2026, 10, 8, 0, 0, 0, 0, time.UTC) + consume := func(day time.Time, amount, limit int64) int64 { + t.Helper() + var got int64 + require.NoError(t, txm.WithTransaction(ctx, func(ctx context.Context) error { + var err error + got, err = repo.Consume(ctx, org, constants.GameRewardScopeUser, customer, day, amount, limit) + return err + })) + return got + } + + assert.EqualValues(t, 30, consume(today, 30, 40)) + assert.EqualValues(t, 10, consume(today, 30, 40), "cut to what is left") + assert.EqualValues(t, 0, consume(today, 5, 40)) + assert.EqualValues(t, 5, consume(today, 5, 0), "no limit, still counted") + assert.EqualValues(t, 30, consume(tomorrow, 30, 40), "a new day starts over") + got, err := repo.Get(ctx, org, constants.GameRewardScopeUser, customer, today) + require.NoError(t, err) + assert.EqualValues(t, 45, got) + + require.NoError(t, txm.WithTransaction(ctx, func(ctx context.Context) error { + return repo.Release(ctx, org, constants.GameRewardScopeUser, customer, today, 15) + })) + got, _ = repo.Get(ctx, org, constants.GameRewardScopeUser, customer, today) + assert.EqualValues(t, 30, got) + assert.Error(t, txm.WithTransaction(ctx, func(ctx context.Context) error { + return repo.Release(ctx, org, constants.GameRewardScopeUser, customer, today, 31) + }), "cannot release more than counted") + + // A rolled back reward leaves the counter as it was. + _ = txm.WithTransaction(ctx, func(ctx context.Context) error { + _, err := repo.Consume(ctx, org, constants.GameRewardScopeUser, customer, today, 10, 0) + require.NoError(t, err) + return assert.AnError + }) + got, _ = repo.Get(ctx, org, constants.GameRewardScopeUser, customer, today) + assert.EqualValues(t, 30, got) + + _, err = repo.Consume(ctx, org, constants.GameRewardScopeUser, customer, today, 1, 0) + assert.ErrorIs(t, err, ErrGameRewardCounterTxRequired) + got, err = repo.Get(ctx, org, constants.GameRewardScopeGlobal, org, today) + require.NoError(t, err) + assert.Zero(t, got) +} diff --git a/internal/repository/game_session_repository.go b/internal/repository/game_session_repository.go new file mode 100644 index 0000000..5af53d7 --- /dev/null +++ b/internal/repository/game_session_repository.go @@ -0,0 +1,239 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" +) + +// ErrGameSessionNotFound means no such session, or not one of the customer's. +var ErrGameSessionNotFound = errors.New("enakgame: session not found") + +// DueGameSession is a STARTED session the session job has to look at, with the status +// of its game. +type DueGameSession struct { + ID uuid.UUID + OrganizationID uuid.UUID + CustomerID uuid.UUID + GameStatus string +} + +// GameSessionCompletion is what completing a session stores. +type GameSessionCompletion struct { + Result entities.JSONDocument + RewardBreakdown entities.JSONDocument + RewardTotal int64 + Flagged bool + EndedAt time.Time +} + +// GameSessionRepository stores game sessions and the rewards they paid +// (docs/rfc-enakgame.md §5.3, §5.4). +// +// A session leaves STARTED once: CompleteSession, RefundSession and ExpireSession are +// each one UPDATE ... WHERE status = 'STARTED' and report whether they made the move, +// so of two at once on the same session exactly one wins (D4). +type GameSessionRepository interface { + CreateSession(ctx context.Context, session *entities.GameSession) error + GetSession(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameSession, error) + // GetCustomerSession returns ErrGameSessionNotFound for another customer's session. + GetCustomerSession(ctx context.Context, customerID, id uuid.UUID) (*entities.GameSession, error) + // GetSessionBySpendTransaction finds the session an entry cost debit paid for, to + // replay a start sent twice. + GetSessionBySpendTransaction(ctx context.Context, spendTransactionID uuid.UUID) (*entities.GameSession, error) + // ListCustomerSessions returns a page of a customer's sessions, newest first, and + // the total. + ListCustomerSessions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]entities.GameSession, int64, error) + + CompleteSession(ctx context.Context, id uuid.UUID, completion GameSessionCompletion) (bool, error) + RefundSession(ctx context.Context, id, refundTransactionID uuid.UUID, reason string, endedAt time.Time) (bool, error) + ExpireSession(ctx context.Context, id uuid.UUID, endedAt time.Time) (bool, error) + // MarkCompletionFailed records that completing a STARTED session failed on a system + // error (§7.3), and reports false when the session is no longer STARTED. + MarkCompletionFailed(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) + + // ListExpiredSessions returns STARTED sessions whose expiry is at or before now, + // oldest expiry first. + ListExpiredSessions(ctx context.Context, now time.Time, limit int) ([]entities.GameSession, error) + // ListDueSessions returns STARTED sessions that have expired at now or whose game is + // no longer ACTIVE (§7.3), oldest expiry first. + ListDueSessions(ctx context.Context, now time.Time, limit int) ([]DueGameSession, error) + // ListOpenSessionsByGame returns a game's STARTED sessions, oldest first. + ListOpenSessionsByGame(ctx context.Context, organizationID, gameID uuid.UUID, limit int) ([]entities.GameSession, error) + + CreateSessionRewards(ctx context.Context, rewards []entities.GameSessionReward) error + ListSessionRewards(ctx context.Context, sessionID uuid.UUID) ([]entities.GameSessionReward, error) +} + +type gameSessionRepository struct { + db *gorm.DB +} + +func NewGameSessionRepository(db *gorm.DB) GameSessionRepository { + return &gameSessionRepository{db: db} +} + +func (r *gameSessionRepository) CreateSession(ctx context.Context, session *entities.GameSession) error { + if session.Status == "" { + session.Status = constants.GameSessionStatusStarted + } + if session.StartedAt.IsZero() { + session.StartedAt = time.Now() + } + if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(session).Error; err != nil { + return fmt.Errorf("failed to create game session: %w", err) + } + return nil +} + +func (r *gameSessionRepository) first(q *gorm.DB) (*entities.GameSession, error) { + var session entities.GameSession + if err := q.First(&session).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGameSessionNotFound + } + return nil, fmt.Errorf("failed to read game session: %w", err) + } + return &session, nil +} + +func (r *gameSessionRepository) GetSession(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameSession, error) { + return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("organization_id = ? AND id = ?", organizationID, id)) +} + +func (r *gameSessionRepository) GetCustomerSession(ctx context.Context, customerID, id uuid.UUID) (*entities.GameSession, error) { + return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("customer_id = ? AND id = ?", customerID, id)) +} + +func (r *gameSessionRepository) GetSessionBySpendTransaction(ctx context.Context, spendTransactionID uuid.UUID) (*entities.GameSession, error) { + return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("spend_transaction_id = ?", spendTransactionID)) +} + +func (r *gameSessionRepository) ListCustomerSessions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]entities.GameSession, int64, error) { + q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameSession{}).Where("customer_id = ?", customerID) + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count game sessions: %w", err) + } + var sessions []entities.GameSession + if err := q.Order("started_at DESC, id").Offset(offset).Limit(limit).Find(&sessions).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list game sessions: %w", err) + } + return sessions, total, nil +} + +// leaveStarted runs one conditional move out of STARTED and reports whether it +// happened. +func (r *gameSessionRepository) leaveStarted(ctx context.Context, id uuid.UUID, set string, args ...any) (bool, error) { + args = append(args, id, constants.GameSessionStatusStarted) + result := DBFromContext(ctx, r.db).WithContext(ctx).Exec( + `UPDATE game_sessions SET `+set+` WHERE id = ? AND status = ?`, args...) + if result.Error != nil { + return false, fmt.Errorf("failed to update game session: %w", result.Error) + } + return result.RowsAffected == 1, nil +} + +func (r *gameSessionRepository) CompleteSession(ctx context.Context, id uuid.UUID, c GameSessionCompletion) (bool, error) { + return r.leaveStarted(ctx, id, + `status = ?, result = ?::jsonb, reward_breakdown = ?::jsonb, reward_total = ?, flagged = ?, ended_at = ?`, + constants.GameSessionStatusCompleted, c.Result, c.RewardBreakdown, c.RewardTotal, c.Flagged, c.EndedAt) +} + +func (r *gameSessionRepository) RefundSession(ctx context.Context, id, refundTransactionID uuid.UUID, reason string, endedAt time.Time) (bool, error) { + return r.leaveStarted(ctx, id, + `status = ?, refund_transaction_id = ?, refund_reason = ?, ended_at = ?`, + constants.GameSessionStatusRefunded, refundTransactionID, reason, endedAt) +} + +func (r *gameSessionRepository) ExpireSession(ctx context.Context, id uuid.UUID, endedAt time.Time) (bool, error) { + return r.leaveStarted(ctx, id, `status = ?, ended_at = ?`, constants.GameSessionStatusExpired, endedAt) +} + +func (r *gameSessionRepository) MarkCompletionFailed(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) { + result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` + UPDATE game_sessions SET completion_failed_at = ? WHERE id = ? AND status = ?`, + at, id, constants.GameSessionStatusStarted) + if result.Error != nil { + return false, fmt.Errorf("failed to mark game session: %w", result.Error) + } + return result.RowsAffected == 1, nil +} + +func (r *gameSessionRepository) ListExpiredSessions(ctx context.Context, now time.Time, limit int) ([]entities.GameSession, error) { + var sessions []entities.GameSession + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("status = ? AND expires_at <= ?", constants.GameSessionStatusStarted, now). + Order("expires_at, id").Limit(limit).Find(&sessions).Error + if err != nil { + return nil, fmt.Errorf("failed to list expired game sessions: %w", err) + } + return sessions, nil +} + +func (r *gameSessionRepository) ListOpenSessionsByGame(ctx context.Context, organizationID, gameID uuid.UUID, limit int) ([]entities.GameSession, error) { + var sessions []entities.GameSession + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("organization_id = ? AND game_id = ? AND status = ?", organizationID, gameID, constants.GameSessionStatusStarted). + Order("started_at, id").Limit(limit).Find(&sessions).Error + if err != nil { + return nil, fmt.Errorf("failed to list open game sessions: %w", err) + } + return sessions, nil +} + +func (r *gameSessionRepository) CreateSessionRewards(ctx context.Context, rewards []entities.GameSessionReward) error { + if len(rewards) == 0 { + return nil + } + if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(&rewards).Error; err != nil { + return fmt.Errorf("failed to record game session rewards: %w", err) + } + return nil +} + +func (r *gameSessionRepository) ListSessionRewards(ctx context.Context, sessionID uuid.UUID) ([]entities.GameSessionReward, error) { + var rewards []entities.GameSessionReward + err := DBFromContext(ctx, r.db).WithContext(ctx).Where("session_id = ?", sessionID).Order("budget_id").Find(&rewards).Error + if err != nil { + return nil, fmt.Errorf("failed to list game session rewards: %w", err) + } + return rewards, nil +} + +func (r *gameSessionRepository) ListDueSessions(ctx context.Context, now time.Time, limit int) ([]DueGameSession, error) { + var rows []struct { + ID string + OrganizationID string + CustomerID string + GameStatus string + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT s.id::text AS id, s.organization_id::text AS organization_id, s.customer_id::text AS customer_id, + g.status AS game_status + FROM game_sessions s + JOIN games g ON g.id = s.game_id + WHERE s.status = ? AND (s.expires_at <= ? OR g.status <> ?) + ORDER BY s.expires_at, s.id + LIMIT ?`, + constants.GameSessionStatusStarted, now, constants.GameStatusActive, limit).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to list due game sessions: %w", err) + } + due := make([]DueGameSession, 0, len(rows)) + for _, row := range rows { + d := DueGameSession{GameStatus: row.GameStatus} + d.ID, _ = uuid.Parse(row.ID) + d.OrganizationID, _ = uuid.Parse(row.OrganizationID) + d.CustomerID, _ = uuid.Parse(row.CustomerID) + due = append(due, d) + } + return due, nil +} diff --git a/internal/repository/loyalty_settings_repository.go b/internal/repository/loyalty_settings_repository.go index 757634f..3c35ef8 100644 --- a/internal/repository/loyalty_settings_repository.go +++ b/internal/repository/loyalty_settings_repository.go @@ -25,8 +25,8 @@ type LoyaltySettingChangeFilter struct { Limit int } -// LoyaltySettingsRepository stores the raw loyalty settings (the loyalty.* keys of -// outlet_settings and organization_settings) and their history +// 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 { @@ -81,7 +81,7 @@ func (r *loyaltySettingsRepository) values(ctx context.Context, table, scopeColu err := DBFromContext(ctx, r.db).WithContext(ctx). Table(table). Select("key, value"). - Where(scopeColumn+" = ? AND key LIKE 'loyalty.%' AND value IS NOT NULL", scopeID). + 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) diff --git a/internal/repository/pg_errors.go b/internal/repository/pg_errors.go new file mode 100644 index 0000000..86e52cf --- /dev/null +++ b/internal/repository/pg_errors.go @@ -0,0 +1,25 @@ +package repository + +import ( + "errors" + + "github.com/jackc/pgx/v5/pgconn" +) + +// isConstraintViolation reports whether err is Postgres refusing a row on the named +// constraint or unique index, with the given SQLSTATE. +func isConstraintViolation(err error, code, constraint string) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == code && pgErr.ConstraintName == constraint +} + +func isUniqueViolation(err error, constraint string) bool { + return isConstraintViolation(err, "23505", constraint) +} + +// isForeignKeyViolation reports whether err is a row still referenced from elsewhere, +// or pointing at one that does not exist, whatever the constraint. +func isForeignKeyViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23503" +} diff --git a/internal/repository/voucher_redemption_repository.go b/internal/repository/voucher_redemption_repository.go new file mode 100644 index 0000000..17797c8 --- /dev/null +++ b/internal/repository/voucher_redemption_repository.go @@ -0,0 +1,291 @@ +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 +} diff --git a/internal/repository/voucher_repository.go b/internal/repository/voucher_repository.go new file mode 100644 index 0000000..14a14fe --- /dev/null +++ b/internal/repository/voucher_repository.go @@ -0,0 +1,341 @@ +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 +} diff --git a/internal/repository/voucher_repository_test.go b/internal/repository/voucher_repository_test.go new file mode 100644 index 0000000..2f78e5f --- /dev/null +++ b/internal/repository/voucher_repository_test.go @@ -0,0 +1,70 @@ +package repository + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// EG-501: what the voucher tables refuse (docs/rfc-enakgame.md §5.7). Needs +// TEST_DATABASE_URL; see wallet_repository_test.go. +func TestVoucherSchema_RejectsWhatTheRFCForbids(t *testing.T) { + db := walletTestDB(t) + org, customer := uuid.New(), uuid.New() + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'voucher test', 'basic')`, org).Error) + require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'v')`, customer, org).Error) + t.Cleanup(func() { + db.Exec(`DELETE FROM voucher_redemptions WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM voucher_codes WHERE voucher_id IN (SELECT id FROM vouchers WHERE organization_id = ?)`, org) + db.Exec(`DELETE FROM vouchers WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM wallet_transactions WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM customers WHERE id = ?`, customer) + db.Exec(`DELETE FROM organizations WHERE id = ?`, org) + }) + voucher := func(mode string, stock any, provider any) error { + return db.Exec(`INSERT INTO vouchers (organization_id, name, voucher_type, face_value, point_cost, stock_mode, stock, provider) + VALUES (?, 'v', 'FIXED_VALUE', 10000, 8000, ?, ?, ?)`, org, mode, stock, provider).Error + } + rejected := func(t *testing.T, err error, constraint string) { + t.Helper() + require.Error(t, err) + assert.Contains(t, err.Error(), constraint) + } + + rejected(t, voucher("STATIC", nil, nil), "chk_vouchers_static_stock") + rejected(t, voucher("CODE_POOL", 5, nil), "chk_vouchers_static_stock") + rejected(t, voucher("EXTERNAL", nil, nil), "chk_vouchers_external_provider") + rejected(t, voucher("STATIC", 5, "acme"), "chk_vouchers_external_provider") + rejected(t, voucher("STATIC", -1, nil), "chk_vouchers_stock") + require.NoError(t, voucher("STATIC", 5, nil)) + require.NoError(t, voucher("EXTERNAL", nil, "acme")) + + poolID := uuid.New() + require.NoError(t, db.Exec(`INSERT INTO vouchers (id, organization_id, name, voucher_type, face_value, point_cost, stock_mode) + VALUES (?, ?, 'pool', 'FIXED_VALUE', 10000, 8000, 'CODE_POOL')`, poolID, org).Error) + rejected(t, db.Exec(`INSERT INTO voucher_codes (voucher_id, code, status) VALUES (?, 'A', 'REDEEMED')`, poolID).Error, "chk_voucher_codes_redemption") + rejected(t, db.Exec(`INSERT INTO voucher_codes (voucher_id, code, status, redemption_id) VALUES (?, 'B', 'AVAILABLE', ?)`, poolID, uuid.New()).Error, "chk_voucher_codes_redemption") + require.NoError(t, db.Exec(`INSERT INTO voucher_codes (voucher_id, code) VALUES (?, 'C')`, poolID).Error) + rejected(t, db.Exec(`INSERT INTO voucher_codes (voucher_id, code) VALUES (?, 'C')`, poolID).Error, "uq_voucher_codes_code") + + debit := func() uuid.UUID { + id := uuid.New() + require.NoError(t, db.Exec(`INSERT INTO wallet_transactions (id, organization_id, customer_id, currency, type, amount, balance_after, reference_type, reference_id, description) + VALUES (?, ?, ?, 'POINT', 'REWARD_REDEEM', -8000, 0, 'REWARD_REDEMPTION', ?, 'x')`, id, org, customer, uuid.New()).Error) + return id + } + redemption := func(key string, status string, completed bool) error { + var completedAt any + if completed { + completedAt = "2026-10-07T00:00:00Z" + } + return db.Exec(`INSERT INTO voucher_redemptions (organization_id, customer_id, voucher_id, idempotency_key, status, face_value, point_cost, debit_transaction_id, completed_at) + VALUES (?, ?, ?, ?, ?, 10000, 8000, ?, ?)`, org, customer, poolID, key, status, debit(), completedAt).Error + } + require.NoError(t, redemption("k1", "COMPLETED", true)) + rejected(t, redemption("k1", "COMPLETED", true), "uq_voucher_redemptions_key") + rejected(t, redemption("k2", "FAILED", false), "chk_voucher_redemptions_refund") + rejected(t, redemption("k3", "COMPLETED", false), "chk_voucher_redemptions_completed") +} diff --git a/internal/router/router.go b/internal/router/router.go index 22dbb63..0f097bc 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -61,12 +61,14 @@ type Router struct { customerDeviceHandler *handler.CustomerDeviceHandler customerOutletHandler *handler.CustomerOutletHandler customerOrderHandler *handler.CustomerOrderHandler + enakGameAdminHandler *handler.EnakGameAdminHandler + enakGameCustomerHandler *handler.EnakGameCustomerHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, customerOutletService service.CustomerOutletService, customerOrderService service.CustomerOrderService, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, customerOutletService service.CustomerOutletService, customerOrderService service.CustomerOrderService, enakGameAdminService service.EnakGameAdminService, enakGameCustomerService service.EnakGameCustomerService, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -119,6 +121,8 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer customerDeviceHandler: handler.NewCustomerDeviceHandler(customerDeviceService), customerOutletHandler: handler.NewCustomerOutletHandler(customerOutletService), customerOrderHandler: handler.NewCustomerOrderHandler(customerOrderService), + enakGameAdminHandler: handler.NewEnakGameAdminHandler(enakGameAdminService), + enakGameCustomerHandler: handler.NewEnakGameCustomerHandler(enakGameCustomerService), redisClient: redisClient, } } @@ -190,6 +194,20 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/games", r.customerPointsHandler.GetCustomerGames) customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame) customer.POST("/spin", r.spinGameHandler.PlaySpinGame) + + // EnakGame (docs/rfc-enakgame.md §11). /enakgame because /customer/games is + // the old spin. + enakGame := customer.Group("/enakgame") + { + enakGame.GET("/games", r.enakGameCustomerHandler.ListGames) + enakGame.POST("/sessions", r.enakGameCustomerHandler.StartSession) + enakGame.GET("/sessions", r.enakGameCustomerHandler.ListSessions) + enakGame.GET("/sessions/:id", r.enakGameCustomerHandler.GetSession) + enakGame.POST("/sessions/:id/complete", r.enakGameCustomerHandler.CompleteSession) + enakGame.GET("/vouchers", r.enakGameCustomerHandler.ListVouchers) + enakGame.POST("/vouchers/:id/redeem", r.enakGameCustomerHandler.RedeemVoucher) + enakGame.GET("/redemptions", r.enakGameCustomerHandler.ListRedemptions) + } } selfOrder := v1.Group("/self-order") @@ -635,6 +653,42 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { // Trace one ledger row lot by lot back to where its balance came from (F7, §8.1) gamification.GET("/wallet-transactions/:id/trace", r.walletAdminHandler.TraceTransaction) + // EnakGame (docs/rfc-enakgame.md §11). Reward configurations and budgets + // change what rewards cost, so writing them takes a loyalty manager. + enakGame := gamification.Group("/enakgame") + { + enakGame.POST("/games", r.enakGameAdminHandler.CreateGame) + enakGame.GET("/games", r.enakGameAdminHandler.ListGames) + enakGame.GET("/games/:id", r.enakGameAdminHandler.GetGame) + enakGame.PUT("/games/:id", r.enakGameAdminHandler.UpdateGame) + enakGame.PUT("/games/:id/status", r.enakGameAdminHandler.SetGameStatus) + enakGame.GET("/games/:id/reward-configs", r.enakGameAdminHandler.ListRewardConfigs) + enakGame.POST("/games/:id/reward-configs", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.CreateRewardConfig) + enakGame.POST("/reward-configs/:id/activate", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.ActivateRewardConfig) + + enakGame.GET("/budgets", r.enakGameAdminHandler.ListBudgets) + enakGame.GET("/budgets/:id", r.enakGameAdminHandler.GetBudget) + enakGame.GET("/budgets/:id/metrics", r.enakGameAdminHandler.BudgetMetrics) + enakGame.POST("/budgets", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.CreateBudget) + enakGame.PUT("/budgets/:id", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.UpdateBudget) + enakGame.DELETE("/budgets/:id", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.DeleteBudget) + + // Events change what rewards cost, like budgets. + enakGame.GET("/events", r.enakGameAdminHandler.ListEvents) + enakGame.GET("/events/:id", r.enakGameAdminHandler.GetEvent) + enakGame.POST("/events", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.CreateEvent) + enakGame.PUT("/events/:id", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.UpdateEvent) + enakGame.PUT("/events/:id/status", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.SetEventStatus) + + enakGame.GET("/vouchers", r.enakGameAdminHandler.ListVouchers) + enakGame.GET("/vouchers/:id", r.enakGameAdminHandler.GetVoucher) + enakGame.GET("/vouchers/:id/codes", r.enakGameAdminHandler.ListVoucherCodes) + enakGame.POST("/vouchers", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.CreateVoucher) + enakGame.PUT("/vouchers/:id", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.UpdateVoucher) + enakGame.PUT("/vouchers/:id/status", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.SetVoucherStatus) + enakGame.POST("/vouchers/:id/codes", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.ImportVoucherCodes) + } + campaignRules := gamification.Group("/campaign-rules") { campaignRules.POST("", r.campaignHandler.CreateCampaignRule) diff --git a/internal/service/enakgame_jobs.go b/internal/service/enakgame_jobs.go new file mode 100644 index 0000000..b02104b --- /dev/null +++ b/internal/service/enakgame_jobs.go @@ -0,0 +1,165 @@ +package service + +import ( + "context" + "sync" + "time" + + "apskel-pos-be/internal/logger" +) + +const ( + defaultGameSessionJobInterval = time.Minute + defaultGameBudgetPeriodJobInterval = 24 * time.Hour +) + +// tickerJob runs a function now and then on every tick until stopped. It keeps no +// state in memory: the work it runs is safe on several instances at once. +type tickerJob struct { + name string + run func(ctx context.Context) + stopCh chan struct{} + stopOnce sync.Once +} + +func (j *tickerJob) start(interval, fallback time.Duration) { + if interval <= 0 { + interval = fallback + } + go func() { + j.run(context.Background()) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + j.run(context.Background()) + case <-j.stopCh: + return + } + } + }() + logger.NonContext.Infof("%s started (interval: %s)", j.name, interval) +} + +func (j *tickerJob) stop() { + j.stopOnce.Do(func() { close(j.stopCh) }) +} + +type gameSessionWork interface { + ProcessDueSessions(ctx context.Context) (refunded, expired int, err error) +} + +// GameSessionJob refunds or expires the EnakGame sessions left STARTED +// (docs/rfc-enakgame.md §7.3). Every session is handled under its customer's wallet +// lock with a conditional update, and the refund carries an idempotency key, so +// several instances and repeated runs refund nothing twice. +type GameSessionJob struct{ job tickerJob } + +func NewGameSessionJob(work gameSessionWork) *GameSessionJob { + j := &GameSessionJob{} + j.job = tickerJob{name: "Game session job", stopCh: make(chan struct{}), run: func(ctx context.Context) { + refunded, expired, err := work.ProcessDueSessions(ctx) + if err != nil { + logger.NonContext.Error("Game session job failed", err) + } + if refunded > 0 || expired > 0 { + logger.NonContext.Infof("Game session job refunded %d and expired %d sessions", refunded, expired) + } + }} + return j +} + +func (j *GameSessionJob) Start(interval time.Duration) { + j.job.start(interval, defaultGameSessionJobInterval) +} +func (j *GameSessionJob) Stop() { j.job.stop() } + +type gameBudgetPeriodWork interface { + CreateNextPeriods(ctx context.Context) (int, error) +} + +// GameBudgetPeriodJob gives each running global EnakGame budget its successor for the +// following month, when there is none yet (§12). The unique index on the period start +// keeps repeated runs and several instances from creating it twice. +type GameBudgetPeriodJob struct{ job tickerJob } + +func NewGameBudgetPeriodJob(work gameBudgetPeriodWork) *GameBudgetPeriodJob { + j := &GameBudgetPeriodJob{} + j.job = tickerJob{name: "Game budget period job", stopCh: make(chan struct{}), run: func(ctx context.Context) { + created, err := work.CreateNextPeriods(ctx) + if err != nil { + logger.NonContext.Error("Game budget period job failed", err) + } + if created > 0 { + logger.NonContext.Infof("Game budget period job created %d budgets", created) + } + }} + return j +} + +func (j *GameBudgetPeriodJob) Start(interval time.Duration) { + j.job.start(interval, defaultGameBudgetPeriodJobInterval) +} +func (j *GameBudgetPeriodJob) Stop() { j.job.stop() } + +const defaultVoucherCodeExpiryJobInterval = time.Hour + +type voucherCodeExpiryWork interface { + ExpireCodes(ctx context.Context, now time.Time) (int64, error) +} + +// VoucherCodeExpiryJob moves the voucher codes past their expiry from AVAILABLE to +// EXPIRED (docs/rfc-enakgame.md §12). Redemption already skips them; this keeps the +// counts honest. Rows are taken with SKIP LOCKED, so instances share the work. +type VoucherCodeExpiryJob struct{ job tickerJob } + +func NewVoucherCodeExpiryJob(work voucherCodeExpiryWork) *VoucherCodeExpiryJob { + j := &VoucherCodeExpiryJob{} + j.job = tickerJob{name: "Voucher code expiry job", stopCh: make(chan struct{}), run: func(ctx context.Context) { + expired, err := work.ExpireCodes(ctx, time.Now()) + if err != nil { + logger.NonContext.Error("Voucher code expiry job failed", err) + } + if expired > 0 { + logger.NonContext.Infof("Voucher code expiry job expired %d codes", expired) + } + }} + return j +} + +func (j *VoucherCodeExpiryJob) Start(interval time.Duration) { + j.job.start(interval, defaultVoucherCodeExpiryJobInterval) +} +func (j *VoucherCodeExpiryJob) Stop() { j.job.stop() } + +const defaultVoucherRecoveryJobInterval = time.Minute + +type voucherRecoveryWork interface { + RecoverPending(ctx context.Context) (completed, failed int, err error) +} + +// VoucherRedemptionRecoveryJob settles the EXTERNAL voucher redemptions their provider +// left without an answer (docs/rfc-enakgame.md §7.5 step 4). Redemptions are claimed +// with SKIP LOCKED and settled with conditional updates, so several instances and +// repeated runs never refund one twice. +type VoucherRedemptionRecoveryJob struct{ job tickerJob } + +func NewVoucherRedemptionRecoveryJob(work voucherRecoveryWork) *VoucherRedemptionRecoveryJob { + j := &VoucherRedemptionRecoveryJob{} + j.job = tickerJob{name: "Voucher redemption recovery job", stopCh: make(chan struct{}), run: func(ctx context.Context) { + completed, failed, err := work.RecoverPending(ctx) + if err != nil { + logger.NonContext.Error("Voucher redemption recovery job failed", err) + } + if completed > 0 || failed > 0 { + logger.NonContext.Infof("Voucher redemption recovery job completed %d and failed %d redemptions", completed, failed) + } + }} + return j +} + +func (j *VoucherRedemptionRecoveryJob) Start(interval time.Duration) { + j.job.start(interval, defaultVoucherRecoveryJobInterval) +} +func (j *VoucherRedemptionRecoveryJob) Stop() { j.job.stop() } diff --git a/internal/service/enakgame_service.go b/internal/service/enakgame_service.go new file mode 100644 index 0000000..1e5daa5 --- /dev/null +++ b/internal/service/enakgame_service.go @@ -0,0 +1,395 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/google/uuid" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" +) + +// EnakGameAdminService is the dashboard side of EnakGame: games, reward +// configurations and budgets of the admin's organization (docs/rfc-enakgame.md §11). +// Bodies are read strictly, so a misspelt field is refused instead of ignored. +type EnakGameAdminService interface { + CreateGame(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response + ListGames(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameListQuery) *contract.Response + GetGame(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + // UpdateGame changes the fields the body sends and keeps the others. + UpdateGame(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response + SetGameStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response + + CreateRewardConfig(ctx context.Context, apctx *appcontext.ContextInfo, gameID uuid.UUID, body []byte) *contract.Response + ListRewardConfigs(ctx context.Context, apctx *appcontext.ContextInfo, gameID uuid.UUID) *contract.Response + ActivateRewardConfig(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response + + CreateBudget(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response + ListBudgets(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameBudgetListQuery) *contract.Response + GetBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + // UpdateBudget changes the fields the body sends and keeps the others. + UpdateBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response + DeleteBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + BudgetMetrics(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + + CreateEvent(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response + ListEvents(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameEventListQuery) *contract.Response + GetEvent(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + // UpdateEvent changes the fields the body sends and keeps the others. + UpdateEvent(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response + SetEventStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response + + CreateVoucher(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response + ListVouchers(ctx context.Context, apctx *appcontext.ContextInfo, q models.VoucherListQuery) *contract.Response + GetVoucher(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + // UpdateVoucher changes the fields the body sends and keeps the others. + UpdateVoucher(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response + SetVoucherStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response + // ImportVoucherCodes adds the codes of a CSV to a CODE_POOL voucher. + ImportVoucherCodes(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, csv []byte) *contract.Response + ListVoucherCodes(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, q models.VoucherCodeListQuery) *contract.Response +} + +// EnakGameCustomerService is what the customer app does with EnakGame. +type EnakGameCustomerService interface { + ListGames(ctx context.Context, customerID uuid.UUID) *contract.Response + StartSession(ctx context.Context, customerID uuid.UUID, req *contract.StartGameSessionRequest, idempotencyKey string) *contract.Response + CompleteSession(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) *contract.Response + ListSessions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response + GetSession(ctx context.Context, customerID, sessionID uuid.UUID) *contract.Response + + ListVouchers(ctx context.Context, customerID uuid.UUID) *contract.Response + RedeemVoucher(ctx context.Context, customerID, voucherID uuid.UUID, req *contract.RedeemVoucherRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response + ListRedemptions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response +} + +type EnakGameAdminServiceImpl struct { + games *processor.EnakGameAdminProcessor + budgets *processor.GameBudgetProcessor + metrics *processor.GameBudgetMetricsProcessor + events *processor.GameEventProcessor + vouchers *processor.VoucherAdminProcessor +} + +func NewEnakGameAdminService(games *processor.EnakGameAdminProcessor, budgets *processor.GameBudgetProcessor, metrics *processor.GameBudgetMetricsProcessor, + events *processor.GameEventProcessor, vouchers *processor.VoucherAdminProcessor) *EnakGameAdminServiceImpl { + return &EnakGameAdminServiceImpl{games: games, budgets: budgets, metrics: metrics, events: events, vouchers: vouchers} +} + +type EnakGameCustomerServiceImpl struct { + sessions *processor.GameSessionProcessor + redemptions *processor.VoucherRedemptionProcessor +} + +func NewEnakGameCustomerService(sessions *processor.GameSessionProcessor, redemptions *processor.VoucherRedemptionProcessor) *EnakGameCustomerServiceImpl { + return &EnakGameCustomerServiceImpl{sessions: sessions, redemptions: redemptions} +} + +// respond wraps a processor result, or its error, as a response. +func respond[T any](ctx context.Context, result T, err error) *contract.Response { + if err != nil { + return enakGameErrorResponse(ctx, err) + } + return contract.BuildSuccessResponse(result) +} + +func (s *EnakGameAdminServiceImpl) CreateGame(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response { + var in models.EnakGameInput + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + game, err := s.games.CreateGame(ctx, apctx.OrganizationID, apctx.UserID, in) + return respond(ctx, game, err) +} + +func (s *EnakGameAdminServiceImpl) ListGames(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameListQuery) *contract.Response { + games, err := s.games.ListGames(ctx, apctx.OrganizationID, q) + return respond(ctx, games, err) +} + +func (s *EnakGameAdminServiceImpl) GetGame(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + game, err := s.games.GetGame(ctx, apctx.OrganizationID, id) + return respond(ctx, game, err) +} + +func (s *EnakGameAdminServiceImpl) UpdateGame(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { + current, err := s.games.GetGame(ctx, apctx.OrganizationID, id) + if err != nil { + return enakGameErrorResponse(ctx, err) + } + in := processor.EnakGameInputFrom(current) + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + game, err := s.games.UpdateGame(ctx, apctx.OrganizationID, apctx.UserID, id, in) + return respond(ctx, game, err) +} + +func (s *EnakGameAdminServiceImpl) SetGameStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { + var in models.EnakGameStatusInput + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + game, err := s.games.SetGameStatus(ctx, apctx.OrganizationID, apctx.UserID, id, in) + return respond(ctx, game, err) +} + +func (s *EnakGameAdminServiceImpl) CreateRewardConfig(ctx context.Context, apctx *appcontext.ContextInfo, gameID uuid.UUID, body []byte) *contract.Response { + var in models.GameRewardConfigInput + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + config, err := s.games.CreateRewardConfig(ctx, apctx.OrganizationID, apctx.UserID, gameID, in) + return respond(ctx, config, err) +} + +func (s *EnakGameAdminServiceImpl) ListRewardConfigs(ctx context.Context, apctx *appcontext.ContextInfo, gameID uuid.UUID) *contract.Response { + configs, err := s.games.ListRewardConfigs(ctx, apctx.OrganizationID, gameID) + return respond(ctx, configs, err) +} + +func (s *EnakGameAdminServiceImpl) ActivateRewardConfig(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { + var in models.GameRewardConfigActivateInput + if len(bytes.TrimSpace(body)) > 0 { + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + } + config, err := s.games.ActivateRewardConfig(ctx, apctx.OrganizationID, apctx.UserID, id, in) + return respond(ctx, config, err) +} + +func (s *EnakGameAdminServiceImpl) CreateBudget(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response { + var in models.GameBudgetInput + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + budget, err := s.budgets.CreateBudget(ctx, apctx.OrganizationID, apctx.UserID, in) + return respond(ctx, budget, err) +} + +func (s *EnakGameAdminServiceImpl) ListBudgets(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameBudgetListQuery) *contract.Response { + budgets, err := s.budgets.ListBudgets(ctx, apctx.OrganizationID, q) + return respond(ctx, budgets, err) +} + +func (s *EnakGameAdminServiceImpl) GetBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + budget, err := s.budgets.GetBudget(ctx, apctx.OrganizationID, id) + return respond(ctx, budget, err) +} + +func (s *EnakGameAdminServiceImpl) UpdateBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { + current, err := s.budgets.GetBudget(ctx, apctx.OrganizationID, id) + if err != nil { + return enakGameErrorResponse(ctx, err) + } + in := processor.GameBudgetInputFrom(current) + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + budget, err := s.budgets.UpdateBudget(ctx, apctx.OrganizationID, apctx.UserID, id, in) + return respond(ctx, budget, err) +} + +func (s *EnakGameAdminServiceImpl) DeleteBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + if err := s.budgets.DeleteBudget(ctx, apctx.OrganizationID, apctx.UserID, id); err != nil { + return enakGameErrorResponse(ctx, err) + } + return contract.BuildSuccessResponse(map[string]any{"id": id, "deleted": true}) +} + +func (s *EnakGameAdminServiceImpl) BudgetMetrics(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + metrics, err := s.metrics.Metrics(ctx, apctx.OrganizationID, id) + return respond(ctx, metrics, err) +} + +func (s *EnakGameAdminServiceImpl) CreateEvent(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response { + var in models.GameEventInput + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + event, err := s.events.CreateEvent(ctx, apctx.OrganizationID, apctx.UserID, in) + return respond(ctx, event, err) +} + +func (s *EnakGameAdminServiceImpl) ListEvents(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameEventListQuery) *contract.Response { + events, err := s.events.ListEvents(ctx, apctx.OrganizationID, q) + return respond(ctx, events, err) +} + +func (s *EnakGameAdminServiceImpl) GetEvent(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + event, err := s.events.GetEvent(ctx, apctx.OrganizationID, id) + return respond(ctx, event, err) +} + +func (s *EnakGameAdminServiceImpl) UpdateEvent(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { + current, err := s.events.GetEvent(ctx, apctx.OrganizationID, id) + if err != nil { + return enakGameErrorResponse(ctx, err) + } + in := processor.GameEventInputFrom(current) + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + event, err := s.events.UpdateEvent(ctx, apctx.OrganizationID, apctx.UserID, id, in) + return respond(ctx, event, err) +} + +func (s *EnakGameAdminServiceImpl) SetEventStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { + var in models.GameEventStatusInput + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + event, err := s.events.SetEventStatus(ctx, apctx.OrganizationID, apctx.UserID, id, in) + return respond(ctx, event, err) +} + +func (s *EnakGameAdminServiceImpl) CreateVoucher(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response { + var in models.VoucherInput + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + voucher, err := s.vouchers.CreateVoucher(ctx, apctx.OrganizationID, apctx.UserID, in) + return respond(ctx, voucher, err) +} + +func (s *EnakGameAdminServiceImpl) ListVouchers(ctx context.Context, apctx *appcontext.ContextInfo, q models.VoucherListQuery) *contract.Response { + vouchers, err := s.vouchers.ListVouchers(ctx, apctx.OrganizationID, q) + return respond(ctx, vouchers, err) +} + +func (s *EnakGameAdminServiceImpl) GetVoucher(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + voucher, err := s.vouchers.GetVoucher(ctx, apctx.OrganizationID, id) + return respond(ctx, voucher, err) +} + +func (s *EnakGameAdminServiceImpl) UpdateVoucher(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { + current, err := s.vouchers.GetVoucher(ctx, apctx.OrganizationID, id) + if err != nil { + return enakGameErrorResponse(ctx, err) + } + in := processor.VoucherInputFrom(current) + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + voucher, err := s.vouchers.UpdateVoucher(ctx, apctx.OrganizationID, apctx.UserID, id, in) + return respond(ctx, voucher, err) +} + +func (s *EnakGameAdminServiceImpl) SetVoucherStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { + var in models.VoucherStatusInput + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + voucher, err := s.vouchers.SetVoucherStatus(ctx, apctx.OrganizationID, apctx.UserID, id, in) + return respond(ctx, voucher, err) +} + +func (s *EnakGameAdminServiceImpl) ImportVoucherCodes(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, csv []byte) *contract.Response { + result, err := s.vouchers.ImportCodes(ctx, apctx.OrganizationID, apctx.UserID, id, csv) + return respond(ctx, result, err) +} + +func (s *EnakGameAdminServiceImpl) ListVoucherCodes(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, q models.VoucherCodeListQuery) *contract.Response { + codes, err := s.vouchers.Codes(ctx, apctx.OrganizationID, id, q) + return respond(ctx, codes, err) +} + +func (s *EnakGameCustomerServiceImpl) ListVouchers(ctx context.Context, customerID uuid.UUID) *contract.Response { + vouchers, err := s.redemptions.Catalog(ctx, customerID) + return respond(ctx, vouchers, err) +} + +func (s *EnakGameCustomerServiceImpl) RedeemVoucher(ctx context.Context, customerID, voucherID uuid.UUID, req *contract.RedeemVoucherRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response { + result, err := s.redemptions.Redeem(ctx, customerID, voucherID, req.Pin, idempotencyKey, info) + return respond(ctx, result, err) +} + +func (s *EnakGameCustomerServiceImpl) ListRedemptions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response { + redemptions, err := s.redemptions.ListRedemptions(ctx, customerID, page, limit) + return respond(ctx, redemptions, err) +} + +func (s *EnakGameCustomerServiceImpl) ListGames(ctx context.Context, customerID uuid.UUID) *contract.Response { + games, err := s.sessions.ListGames(ctx, customerID) + return respond(ctx, games, err) +} + +func (s *EnakGameCustomerServiceImpl) StartSession(ctx context.Context, customerID uuid.UUID, req *contract.StartGameSessionRequest, idempotencyKey string) *contract.Response { + started, err := s.sessions.Start(ctx, customerID, req.GameID, idempotencyKey) + return respond(ctx, started, err) +} + +func (s *EnakGameCustomerServiceImpl) CompleteSession(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) *contract.Response { + completion, err := s.sessions.Complete(ctx, customerID, sessionID, in) + return respond(ctx, completion, err) +} + +func (s *EnakGameCustomerServiceImpl) ListSessions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response { + sessions, err := s.sessions.ListSessions(ctx, customerID, page, limit) + return respond(ctx, sessions, err) +} + +func (s *EnakGameCustomerServiceImpl) GetSession(ctx context.Context, customerID, sessionID uuid.UUID) *contract.Response { + session, err := s.sessions.GetSession(ctx, customerID, sessionID) + return respond(ctx, session, err) +} + +// decodeStrict reads a JSON body into v, refusing unknown fields and trailing data. +// It returns the error response, or nil when the body fits. +func decodeStrict(body []byte, v any) *contract.Response { + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + err := dec.Decode(v) + if err == nil { + if _, trailing := dec.Token(); trailing != io.EOF { + err = errors.New("unexpected data after the JSON object") + } + } + if err != nil { + return contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, fmt.Sprintf("invalid request body: %v", err)), + }) + } + return nil +} + +// enakGameErrorResponse tells a refused or missing thing apart from a server failure, +// whose details stay in the log. +func enakGameErrorResponse(ctx context.Context, err error) *contract.Response { + var pinErr *processor.PinError + if errors.As(err, &pinErr) { + return PinErrorResponse(err) + } + code, cause := constants.InternalServerErrorCode, "internal error" + switch { + case errors.Is(err, repository.ErrEnakGameNotFound), + errors.Is(err, repository.ErrGameRewardConfigNotFound), + errors.Is(err, repository.ErrGameBudgetNotFound), + errors.Is(err, repository.ErrGameSessionNotFound), + errors.Is(err, repository.ErrVoucherNotFound), + errors.Is(err, repository.ErrGameEventNotFound), + errors.Is(err, repository.ErrWalletNotFound): + code, cause = constants.NotFoundErrorCode, err.Error() + case errors.Is(err, processor.ErrEnakGameRejected), + errors.Is(err, processor.ErrGameSessionRejected), + errors.Is(err, processor.ErrVoucherRedemptionRejected), + errors.Is(err, processor.ErrWalletIdempotencyConflict): + code, cause = constants.ValidationErrorCode, err.Error() + default: + logger.FromContext(ctx).WithError(err).Error("EnakGame request failed") + } + return contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(code, constants.EnakGameServiceEntity, cause), + }) +} diff --git a/migrations/000103_extend_games_for_enakgame.down.sql b/migrations/000103_extend_games_for_enakgame.down.sql new file mode 100644 index 0000000..f8dd7f2 --- /dev/null +++ b/migrations/000103_extend_games_for_enakgame.down.sql @@ -0,0 +1,19 @@ +-- The old games stay inactive: the up migration does not keep their is_active, and +-- their coin cost is still in metadata.coin_cost. + +DROP INDEX IF EXISTS idx_games_org_status; +DROP INDEX IF EXISTS uq_games_org_slug; + +ALTER TABLE games + DROP CONSTRAINT IF EXISTS chk_games_enakgame_identity, + DROP CONSTRAINT IF EXISTS chk_games_entry_cost, + DROP COLUMN IF EXISTS result_rules, + DROP COLUMN IF EXISTS session_ttl_seconds, + DROP COLUMN IF EXISTS entry_cost, + DROP COLUMN IF EXISTS status, + DROP COLUMN IF EXISTS version, + DROP COLUMN IF EXISTS game_url, + DROP COLUMN IF EXISTS thumbnail_url, + DROP COLUMN IF EXISTS description, + DROP COLUMN IF EXISTS slug, + DROP COLUMN IF EXISTS organization_id; diff --git a/migrations/000103_extend_games_for_enakgame.up.sql b/migrations/000103_extend_games_for_enakgame.up.sql new file mode 100644 index 0000000..6a92b2b --- /dev/null +++ b/migrations/000103_extend_games_for_enakgame.up.sql @@ -0,0 +1,42 @@ +-- EnakGame game catalog (docs/rfc-enakgame.md §5.1). games is extended rather than +-- replaced, because the customer app already reads it. + +ALTER TABLE games + ADD COLUMN organization_id UUID, + ADD COLUMN slug VARCHAR(100), + ADD COLUMN description TEXT, + ADD COLUMN thumbnail_url VARCHAR(500), + ADD COLUMN game_url VARCHAR(500), + ADD COLUMN version VARCHAR(50), + ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' + CONSTRAINT chk_games_status CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE', 'ARCHIVED')), + ADD COLUMN entry_cost BIGINT, + ADD COLUMN session_ttl_seconds INT NOT NULL DEFAULT 600 + CONSTRAINT chk_games_session_ttl_seconds CHECK (session_ttl_seconds > 0), + -- Limits the Result Validator checks a result against: max_score, + -- min_duration_seconds, max_score_per_second, the valid outcomes, and the game's + -- daily reward limit (§5.10, §7.2). + ADD COLUMN result_rules JSONB NOT NULL DEFAULT '{}'; + +-- The old games leave the product: archived, never deleted (§14). game_plays.game_id +-- is ON DELETE CASCADE, and the old GAME_SPEND ledger rows point at game_plays. +-- Their coin cost becomes the entry cost; one that is missing, not a whole number or +-- below 1 becomes 1, so the CHECK below holds. +UPDATE games SET + status = 'ARCHIVED', + is_active = FALSE, + entry_cost = CASE + WHEN metadata->>'coin_cost' ~ '^[0-9]{1,18}$' THEN GREATEST((metadata->>'coin_cost')::BIGINT, 1) + ELSE 1 END; + +ALTER TABLE games + ALTER COLUMN entry_cost SET NOT NULL, + -- PRD §10.1: no game is free. + ADD CONSTRAINT chk_games_entry_cost CHECK (entry_cost >= 1), + -- Every EnakGame game has an organization and a slug. Only the archived old games + -- may lack them. + ADD CONSTRAINT chk_games_enakgame_identity CHECK ( + status = 'ARCHIVED' OR (organization_id IS NOT NULL AND slug IS NOT NULL)); + +CREATE UNIQUE INDEX uq_games_org_slug ON games(organization_id, slug) WHERE slug IS NOT NULL; +CREATE INDEX idx_games_org_status ON games(organization_id, status); diff --git a/migrations/000104_create_enakgame_tables.down.sql b/migrations/000104_create_enakgame_tables.down.sql new file mode 100644 index 0000000..0015b21 --- /dev/null +++ b/migrations/000104_create_enakgame_tables.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS game_session_rewards; +DROP TABLE IF EXISTS game_sessions; +DROP TABLE IF EXISTS game_reward_configs; +DROP TABLE IF EXISTS game_budgets; diff --git a/migrations/000104_create_enakgame_tables.up.sql b/migrations/000104_create_enakgame_tables.up.sql new file mode 100644 index 0000000..adeb87c --- /dev/null +++ b/migrations/000104_create_enakgame_tables.up.sql @@ -0,0 +1,120 @@ +-- EnakGame budgets, reward configurations and sessions (docs/rfc-enakgame.md §5.2, +-- §5.3, §5.4, §5.6), in foreign key order. + +-- What an organization may spend on EnakGame rewards (§5.6). The global budget pays for +-- base rewards, one per period; an event budget pays for what its event adds. +CREATE TABLE game_budgets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + scope VARCHAR(20) NOT NULL, + name VARCHAR(255) NOT NULL, + period_start DATE NOT NULL, + period_end DATE NOT NULL, + -- Rupiah. + amount BIGINT NOT NULL, + -- {"warning": 70, "critical": 90}: percent of utilization or forecast (PRD §8, §32). + thresholds JSONB NOT NULL DEFAULT '{}', + -- PRD §34, pending a decision (RFC §19.2). + exhaustion_policy VARCHAR(30), + created_by UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + CONSTRAINT chk_game_budgets_scope CHECK (scope IN ('GLOBAL', 'EVENT')), + CONSTRAINT chk_game_budgets_amount CHECK (amount > 0), + CONSTRAINT chk_game_budgets_period CHECK (period_end >= period_start) +); + +-- Global budgets of an organization do not overlap: one row per period. +CREATE UNIQUE INDEX uq_game_budgets_global_period + ON game_budgets(organization_id, period_start) WHERE scope = 'GLOBAL'; +CREATE INDEX idx_game_budgets_org_scope ON game_budgets(organization_id, scope, period_start DESC); + +-- How a game computes its base reward (§5.2, §8). Immutable apart from status (D7): a +-- change is a new row with the next version, and a session keeps the one it started +-- with. +CREATE TABLE game_reward_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT, + version INT NOT NULL, + reward_type VARCHAR(30) NOT NULL, + -- Shape per reward_type in §8. + rules JSONB NOT NULL, + max_reward BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + effective_at TIMESTAMP WITH TIME ZONE, + created_by UUID NOT NULL, + reason VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_game_reward_configs_version UNIQUE (game_id, version), + CONSTRAINT chk_game_reward_configs_version CHECK (version >= 1), + CONSTRAINT chk_game_reward_configs_reward_type + CHECK (reward_type IN ('FIXED', 'SCORE_BASED', 'OUTCOME_BASED', 'PROBABILITY')), + CONSTRAINT chk_game_reward_configs_max_reward CHECK (max_reward >= 0), + CONSTRAINT chk_game_reward_configs_status CHECK (status IN ('DRAFT', 'ACTIVE', 'RETIRED')) +); + +-- One active configuration per game. +CREATE UNIQUE INDEX uq_game_reward_configs_active + ON game_reward_configs(game_id) WHERE status = 'ACTIVE'; + +-- One play of a game by a customer (§5.3). STARTED → COMPLETED | REFUNDED | EXPIRED, +-- each move an UPDATE ... WHERE status = 'STARTED' (D4). +CREATE TABLE game_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, + game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT, + -- Snapshots taken at start (D7, P3). + reward_config_id UUID NOT NULL REFERENCES game_reward_configs(id), + entry_cost BIGINT NOT NULL, + + status VARCHAR(20) NOT NULL DEFAULT 'STARTED', + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + ended_at TIMESTAMP WITH TIME ZONE, + + -- The result the client sent: data only, never a reward amount (P1). + result JSONB, + -- Validation and calculation: base, event modifiers, guard caps, rejection reasons, + -- RNG roll. + reward_breakdown JSONB, + reward_total BIGINT NOT NULL DEFAULT 0, + flagged BOOLEAN NOT NULL DEFAULT FALSE, + + spend_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), + refund_transaction_id UUID REFERENCES wallet_transactions(id), + refund_reason VARCHAR(30), + -- Set, in a transaction of its own, when completing failed on a system error (§7.3). + completion_failed_at TIMESTAMP WITH TIME ZONE, + + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_game_sessions_spend_transaction UNIQUE (spend_transaction_id), + CONSTRAINT chk_game_sessions_entry_cost CHECK (entry_cost >= 1), + CONSTRAINT chk_game_sessions_status CHECK (status IN ('STARTED', 'COMPLETED', 'REFUNDED', 'EXPIRED')), + CONSTRAINT chk_game_sessions_reward_total CHECK (reward_total >= 0), + CONSTRAINT chk_game_sessions_refund_reason CHECK (refund_reason IN ('SYSTEM_ERROR', 'GAME_DEACTIVATED')), + CONSTRAINT chk_game_sessions_refund CHECK ( + (status = 'REFUNDED') = (refund_transaction_id IS NOT NULL AND refund_reason IS NOT NULL)) +); + +CREATE INDEX idx_game_sessions_customer ON game_sessions(customer_id, started_at DESC); +CREATE INDEX idx_game_sessions_open ON game_sessions(expires_at) WHERE status = 'STARTED'; +CREATE INDEX idx_game_sessions_game_open ON game_sessions(game_id) WHERE status = 'STARTED'; + +-- One row per budget paying for a session's reward (D6), each with its own ledger row. +CREATE TABLE game_session_rewards ( + session_id UUID NOT NULL REFERENCES game_sessions(id), + budget_id UUID NOT NULL REFERENCES game_budgets(id), + amount BIGINT NOT NULL, + wallet_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), + + PRIMARY KEY (session_id, budget_id), + CONSTRAINT uq_game_session_rewards_wallet_transaction UNIQUE (wallet_transaction_id), + CONSTRAINT chk_game_session_rewards_amount CHECK (amount > 0) +); + +CREATE INDEX idx_game_session_rewards_budget ON game_session_rewards(budget_id); diff --git a/migrations/000105_add_enakgame_wallet_types.down.sql b/migrations/000105_add_enakgame_wallet_types.down.sql new file mode 100644 index 0000000..ba228cf --- /dev/null +++ b/migrations/000105_add_enakgame_wallet_types.down.sql @@ -0,0 +1,16 @@ +-- The restored constraints do not mention the EnakGame types, so rows of those types +-- stay valid; only the engine stops writing them. + +ALTER TABLE wallet_transactions + DROP CONSTRAINT chk_wallet_transactions_point_only_types, + DROP CONSTRAINT chk_wallet_transactions_coin_only_types, + DROP CONSTRAINT chk_wallet_transactions_reversal_source; + +ALTER TABLE wallet_transactions + ADD CONSTRAINT chk_wallet_transactions_point_only_types CHECK ( + type NOT IN ('PAYMENT', 'PAYMENT_REFUND', 'EXCHANGE_IN', 'REWARD_REDEEM') + OR currency = 'POINT'), + ADD CONSTRAINT chk_wallet_transactions_coin_only_types CHECK ( + type NOT IN ('EXCHANGE_OUT', 'GAME_SPEND') OR currency = 'COIN'), + ADD CONSTRAINT chk_wallet_transactions_reversal_source CHECK ( + type NOT IN ('EARN_REVERSAL', 'PAYMENT_REFUND') OR reverses_transaction_id IS NOT NULL); diff --git a/migrations/000105_add_enakgame_wallet_types.up.sql b/migrations/000105_add_enakgame_wallet_types.up.sql new file mode 100644 index 0000000..8b2b4b4 --- /dev/null +++ b/migrations/000105_add_enakgame_wallet_types.up.sql @@ -0,0 +1,19 @@ +-- EnakGame ledger types (docs/rfc-enakgame.md §6). Must match walletTypeRules in +-- internal/processor/wallet_processor.go. PAYMENT and PAYMENT_REFUND stay allowed here +-- although the engine no longer writes them (000102). + +ALTER TABLE wallet_transactions + DROP CONSTRAINT chk_wallet_transactions_point_only_types, + DROP CONSTRAINT chk_wallet_transactions_coin_only_types, + DROP CONSTRAINT chk_wallet_transactions_reversal_source; + +ALTER TABLE wallet_transactions + ADD CONSTRAINT chk_wallet_transactions_point_only_types CHECK ( + type NOT IN ('PAYMENT', 'PAYMENT_REFUND', 'EXCHANGE_IN', 'REWARD_REDEEM', 'REWARD_REDEEM_REFUND') + OR currency = 'POINT'), + ADD CONSTRAINT chk_wallet_transactions_coin_only_types CHECK ( + type NOT IN ('EXCHANGE_OUT', 'GAME_SPEND', 'GAME_SPEND_REFUND', 'GAME_REWARD') + OR currency = 'COIN'), + ADD CONSTRAINT chk_wallet_transactions_reversal_source CHECK ( + type NOT IN ('EARN_REVERSAL', 'PAYMENT_REFUND', 'GAME_SPEND_REFUND', 'REWARD_REDEEM_REFUND') + OR reverses_transaction_id IS NOT NULL); diff --git a/migrations/000106_create_audit_logs.down.sql b/migrations/000106_create_audit_logs.down.sql new file mode 100644 index 0000000..acb2224 --- /dev/null +++ b/migrations/000106_create_audit_logs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS audit_logs; diff --git a/migrations/000106_create_audit_logs.up.sql b/migrations/000106_create_audit_logs.up.sql new file mode 100644 index 0000000..ae6ab05 --- /dev/null +++ b/migrations/000106_create_audit_logs.up.sql @@ -0,0 +1,25 @@ +-- Who changed what in EnakGame, from what, to what (docs/rfc-enakgame.md §5.9, §13). +-- Written in the same transaction as the change it records, so no change commits +-- without its row. Append-only. +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + actor_type VARCHAR(20) NOT NULL, + -- The admin for USER; NULL for SYSTEM. + actor_id UUID, + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + action VARCHAR(50) NOT NULL, + before JSONB, + after JSONB, + reason VARCHAR(255), + -- 'admin_api', 'budget_controller', 'session_job', ... + source VARCHAR(50) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + CONSTRAINT chk_audit_logs_actor_type CHECK (actor_type IN ('USER', 'SYSTEM')), + CONSTRAINT chk_audit_logs_actor CHECK (actor_type = 'SYSTEM' OR actor_id IS NOT NULL) +); + +CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id, created_at DESC); +CREATE INDEX idx_audit_logs_organization ON audit_logs(organization_id, created_at DESC); diff --git a/migrations/000107_create_game_reward_counters.down.sql b/migrations/000107_create_game_reward_counters.down.sql new file mode 100644 index 0000000..ff2ff00 --- /dev/null +++ b/migrations/000107_create_game_reward_counters.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS game_reward_counters; diff --git a/migrations/000107_create_game_reward_counters.up.sql b/migrations/000107_create_game_reward_counters.up.sql new file mode 100644 index 0000000..c799dbd --- /dev/null +++ b/migrations/000107_create_game_reward_counters.up.sql @@ -0,0 +1,16 @@ +-- EnakCoin given out as EnakGame rewards per scope per day, in Asia/Jakarta: the +-- Economy Guard's counters (docs/rfc-enakgame.md §5.8, §9). A reward over a limit is +-- cut to what is left of it. +CREATE TABLE game_reward_counters ( + organization_id UUID NOT NULL, + scope_type VARCHAR(20) NOT NULL, + -- The customer, game, event or organization the scope_type names. + scope_id UUID NOT NULL, + -- 0001-01-01 for an event's limit over its whole run (PRD §35 Event Limit). + day DATE NOT NULL, + amount BIGINT NOT NULL DEFAULT 0, + + PRIMARY KEY (organization_id, scope_type, scope_id, day), + CONSTRAINT chk_game_reward_counters_scope_type CHECK (scope_type IN ('USER', 'GAME', 'EVENT', 'GLOBAL')), + CONSTRAINT chk_game_reward_counters_amount CHECK (amount >= 0) +); diff --git a/migrations/000108_create_voucher_tables.down.sql b/migrations/000108_create_voucher_tables.down.sql new file mode 100644 index 0000000..1fdb6b6 --- /dev/null +++ b/migrations/000108_create_voucher_tables.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS voucher_redemption_costs; +DROP TABLE IF EXISTS voucher_redemptions; +DROP TABLE IF EXISTS voucher_codes; +DROP TABLE IF EXISTS vouchers; diff --git a/migrations/000108_create_voucher_tables.up.sql b/migrations/000108_create_voucher_tables.up.sql new file mode 100644 index 0000000..4590178 --- /dev/null +++ b/migrations/000108_create_voucher_tables.up.sql @@ -0,0 +1,123 @@ +-- EnakGame vouchers, what EnakPoint is redeemed for, and what each redemption cost +-- which budget (docs/rfc-enakgame.md §5.7, D5). Replaces the unused rewards table for +-- EnakGame; rewards itself stays. + +CREATE TABLE vouchers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + image_url VARCHAR(500), + voucher_type VARCHAR(30) NOT NULL, + -- Rupiah: what a redemption costs, the basis of realized cost. + face_value BIGINT NOT NULL, + -- EnakPoint asked for it; may differ from face_value (PRD §22). + point_cost BIGINT NOT NULL, + -- Reporting only, never counted against a budget. + business_cost BIGINT, + stock_mode VARCHAR(20) NOT NULL, + -- STATIC only. + stock BIGINT, + -- EXTERNAL only. + provider VARCHAR(50), + provider_ref VARCHAR(255), + max_per_customer INT, + valid_from TIMESTAMP WITH TIME ZONE, + valid_until TIMESTAMP WITH TIME ZONE, + terms JSONB NOT NULL DEFAULT '{}', + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + CONSTRAINT chk_vouchers_voucher_type + CHECK (voucher_type IN ('FIXED_VALUE', 'PERCENTAGE', 'FREE_ITEM', 'MERCHANT_BENEFIT')), + CONSTRAINT chk_vouchers_face_value CHECK (face_value > 0), + CONSTRAINT chk_vouchers_point_cost CHECK (point_cost > 0), + CONSTRAINT chk_vouchers_business_cost CHECK (business_cost IS NULL OR business_cost >= 0), + CONSTRAINT chk_vouchers_stock_mode CHECK (stock_mode IN ('STATIC', 'CODE_POOL', 'EXTERNAL')), + CONSTRAINT chk_vouchers_stock CHECK (stock IS NULL OR stock >= 0), + CONSTRAINT chk_vouchers_static_stock CHECK ((stock_mode = 'STATIC') = (stock IS NOT NULL)), + CONSTRAINT chk_vouchers_external_provider CHECK ((stock_mode = 'EXTERNAL') = (provider IS NOT NULL)), + CONSTRAINT chk_vouchers_max_per_customer CHECK (max_per_customer IS NULL OR max_per_customer >= 1), + CONSTRAINT chk_vouchers_validity CHECK (valid_from IS NULL OR valid_until IS NULL OR valid_until > valid_from), + CONSTRAINT chk_vouchers_status CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE', 'ARCHIVED')) +); + +CREATE INDEX idx_vouchers_org_status ON vouchers(organization_id, status); + +-- The codes of a CODE_POOL voucher, one per redemption. +CREATE TABLE voucher_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + voucher_id UUID NOT NULL REFERENCES vouchers(id) ON DELETE RESTRICT, + code VARCHAR(255) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE', + -- No foreign key: the code is taken before its redemption row is written. + redemption_id UUID, + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_voucher_codes_code UNIQUE (voucher_id, code), + CONSTRAINT chk_voucher_codes_status + CHECK (status IN ('AVAILABLE', 'RESERVED', 'REDEEMED', 'EXPIRED', 'CANCELLED')), + CONSTRAINT chk_voucher_codes_redemption + CHECK ((status IN ('RESERVED', 'REDEEMED')) = (redemption_id IS NOT NULL)) +); + +CREATE INDEX idx_voucher_codes_available ON voucher_codes(voucher_id, created_at) + WHERE status = 'AVAILABLE'; +CREATE INDEX idx_voucher_codes_expiring ON voucher_codes(expires_at) + WHERE status = 'AVAILABLE' AND expires_at IS NOT NULL; + +-- One redemption of a voucher by a customer, with the voucher's numbers frozen (P3). +CREATE TABLE voucher_redemptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, + voucher_id UUID NOT NULL REFERENCES vouchers(id), + idempotency_key VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL, + face_value BIGINT NOT NULL, + point_cost BIGINT NOT NULL, + voucher_code_id UUID REFERENCES voucher_codes(id), + external_code VARCHAR(255), + external_ref VARCHAR(255), + debit_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), + refund_transaction_id UUID REFERENCES wallet_transactions(id), + failure_reason VARCHAR(255), + attempts INT NOT NULL DEFAULT 0, + completed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_voucher_redemptions_key UNIQUE (customer_id, idempotency_key), + CONSTRAINT uq_voucher_redemptions_debit UNIQUE (debit_transaction_id), + CONSTRAINT chk_voucher_redemptions_status CHECK (status IN ('PENDING', 'COMPLETED', 'FAILED')), + CONSTRAINT chk_voucher_redemptions_refund CHECK ((status = 'FAILED') = (refund_transaction_id IS NOT NULL)), + CONSTRAINT chk_voucher_redemptions_completed CHECK ((status = 'COMPLETED') = (completed_at IS NOT NULL)) +); + +CREATE INDEX idx_voucher_redemptions_customer ON voucher_redemptions(customer_id, created_at DESC); +CREATE INDEX idx_voucher_redemptions_customer_voucher ON voucher_redemptions(customer_id, voucher_id); +CREATE INDEX idx_voucher_redemptions_pending ON voucher_redemptions(updated_at) + WHERE status = 'PENDING'; + +-- What a redemption cost each budget, frozen when it completes (D5). +CREATE TABLE voucher_redemption_costs ( + redemption_id UUID NOT NULL REFERENCES voucher_redemptions(id), + -- NULL: EnakPoint that did not come from EnakGame (EARN, ADJUSTMENT, MIGRATION). + budget_id UUID REFERENCES game_budgets(id), + -- Ledger type of the root lot the EnakPoint came from. + source_type VARCHAR(30) NOT NULL, + points BIGINT NOT NULL, + -- Rupiah, a share of face_value. + cost BIGINT NOT NULL, + -- = completed_at, what puts the cost in a budget period. + recognized_at TIMESTAMP WITH TIME ZONE NOT NULL, + + CONSTRAINT uq_voucher_redemption_costs UNIQUE (redemption_id, budget_id, source_type), + CONSTRAINT chk_voucher_redemption_costs_points CHECK (points > 0), + CONSTRAINT chk_voucher_redemption_costs_cost CHECK (cost >= 0) +); + +CREATE INDEX idx_voucher_redemption_costs_budget ON voucher_redemption_costs(budget_id, recognized_at); diff --git a/migrations/000109_index_wallet_lots_origin.down.sql b/migrations/000109_index_wallet_lots_origin.down.sql new file mode 100644 index 0000000..0765b10 --- /dev/null +++ b/migrations/000109_index_wallet_lots_origin.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS idx_wallet_lots_origin_lot_id; diff --git a/migrations/000109_index_wallet_lots_origin.up.sql b/migrations/000109_index_wallet_lots_origin.up.sql new file mode 100644 index 0000000..820d5ca --- /dev/null +++ b/migrations/000109_index_wallet_lots_origin.up.sql @@ -0,0 +1,4 @@ +-- Walks from a lot to the lots carried over from it (transfers, exchanges, refunds), +-- for the exposure of an EnakGame budget (docs/rfc-enakgame.md §10). CONCURRENTLY +-- keeps wallet writes going while it builds, so this file holds one statement. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_wallet_lots_origin_lot_id ON wallet_lots(origin_lot_id) WHERE origin_lot_id IS NOT NULL; diff --git a/migrations/000110_index_wallet_lots_source.down.sql b/migrations/000110_index_wallet_lots_source.down.sql new file mode 100644 index 0000000..2643966 --- /dev/null +++ b/migrations/000110_index_wallet_lots_source.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS idx_wallet_lots_source_transaction_id; diff --git a/migrations/000110_index_wallet_lots_source.up.sql b/migrations/000110_index_wallet_lots_source.up.sql new file mode 100644 index 0000000..1ecf934 --- /dev/null +++ b/migrations/000110_index_wallet_lots_source.up.sql @@ -0,0 +1,3 @@ +-- Finds the lots a ledger row created, such as the lot of an EnakGame reward +-- (docs/rfc-enakgame.md §10). One statement, for CONCURRENTLY. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_wallet_lots_source_transaction_id ON wallet_lots(source_transaction_id); diff --git a/migrations/000111_create_game_events.down.sql b/migrations/000111_create_game_events.down.sql new file mode 100644 index 0000000..2325220 --- /dev/null +++ b/migrations/000111_create_game_events.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS game_event_games; +DROP TABLE IF EXISTS game_events; diff --git a/migrations/000111_create_game_events.up.sql b/migrations/000111_create_game_events.up.sql new file mode 100644 index 0000000..fceb4e2 --- /dev/null +++ b/migrations/000111_create_game_events.up.sql @@ -0,0 +1,47 @@ +-- EnakGame events, also called campaigns (docs/rfc-enakgame.md §5.5, D6): a period in +-- which some games pay more, the extra paid by the event's own budget. +CREATE TABLE game_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL, + description TEXT, + banner_url VARCHAR(500), + start_at TIMESTAMP WITH TIME ZONE NOT NULL, + end_at TIMESTAMP WITH TIME ZONE NOT NULL, + -- Shown with the dates; start_at and end_at are instants already. + timezone VARCHAR(50) NOT NULL DEFAULT 'Asia/Jakarta', + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + -- Higher goes first; when the reward cap cuts, the lowest goes first. + priority INT NOT NULL DEFAULT 0, + -- Applied to the base reward: 2.00 adds the base once more. + multiplier NUMERIC(5,2), + -- Added on top, per completed session. + bonus BIGINT, + -- Pays what this event adds. A budget of scope EVENT, checked by the processor. + budget_id UUID NOT NULL REFERENCES game_budgets(id), + -- EnakCoin this event may add over its whole run (PRD §35 Event Limit). + reward_limit BIGINT, + -- EnakCoin this event may add for one customer per day. + user_daily_limit BIGINT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_game_events_slug UNIQUE (organization_id, slug), + CONSTRAINT chk_game_events_period CHECK (end_at > start_at), + CONSTRAINT chk_game_events_status CHECK (status IN ('DRAFT', 'ACTIVE', 'ENDED', 'CANCELLED')), + CONSTRAINT chk_game_events_multiplier CHECK (multiplier IS NULL OR multiplier > 0), + CONSTRAINT chk_game_events_bonus CHECK (bonus IS NULL OR bonus > 0), + CONSTRAINT chk_game_events_reward_limit CHECK (reward_limit IS NULL OR reward_limit > 0), + CONSTRAINT chk_game_events_user_daily_limit CHECK (user_daily_limit IS NULL OR user_daily_limit > 0) +); + +CREATE INDEX idx_game_events_org_active ON game_events(organization_id, start_at, end_at) WHERE status = 'ACTIVE'; + +CREATE TABLE game_event_games ( + event_id UUID NOT NULL REFERENCES game_events(id) ON DELETE CASCADE, + game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT, + PRIMARY KEY (event_id, game_id) +); + +CREATE INDEX idx_game_event_games_game ON game_event_games(game_id);